blob: f07345851973f89aff70561e70b128f46a7cad8d [file] [log] [blame]
// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {assert} from 'chrome://resources/js/assert.js';
import {BackgroundEl, getGlobalConfig as getBackgroundElGlobalConfig, setGlobalConfig as setBackgroundElGlobalConfig} from './background_el.js';
import {Cloud} from './cloud.js';
import type {Dimensions} from './dimensions.js';
import type {ConfigProvider} from './game_config.js';
import type {GameStateProvider} from './game_state_provider.js';
import {HorizonLine} from './horizon_line.js';
import type {ImageSpriteProvider} from './image_sprite_provider.js';
import {NightMode} from './night_mode.js';
import {Obstacle, setMaxGapCoefficient as setMaxObstacleGapCoefficient, setMaxObstacleLength} from './obstacle.js';
import type {ObstacleType, SpritePositions} from './offline_sprite_definitions.js';
import {spriteDefinitionByType} from './offline_sprite_definitions.js';
import {getRandomNum} from './utils.js';
/**
* Horizon background class.
*/
export class Horizon {
obstacles: Obstacle[] = [];
private canvas: HTMLCanvasElement;
private canvasCtx: CanvasRenderingContext2D;
private config: HorizonConfig = horizonConfig;
private dimensions: Dimensions;
private gapCoefficient: number;
private resourceProvider: ImageSpriteProvider&ConfigProvider&
GameStateProvider;
private obstacleHistory: Array<keyof SpritePositions> = [];
private cloudFrequency: number;
private spritePos: SpritePositions;
private nightMode: NightMode;
private altGameModeActive: boolean = false;
private obstacleTypes: ObstacleType[] = [];
// Cloud
private clouds: Cloud[] = [];
private cloudSpeed: number;
// Background elements
private backgroundEls: BackgroundEl[] = [];
private lastEl: string|null = null;
// Horizon
private horizonLines: HorizonLine[] = [];
constructor(
canvas: HTMLCanvasElement, spritePos: SpritePositions,
dimensions: Dimensions, gapCoefficient: number,
resourceProvider: ImageSpriteProvider&ConfigProvider&GameStateProvider) {
this.canvas = canvas;
const canvasContext = canvas.getContext('2d');
assert(canvasContext);
this.canvasCtx = canvasContext;
this.dimensions = dimensions;
this.gapCoefficient = gapCoefficient;
this.resourceProvider = resourceProvider;
this.cloudFrequency = this.config.CLOUD_FREQUENCY;
this.spritePos = spritePos;
this.cloudSpeed = this.config.BG_CLOUD_SPEED;
// Initialise the horizon. Just add the line and a cloud. No obstacles.
this.obstacleTypes = spriteDefinitionByType.original.obstacles;
this.addCloud();
const runnerSpriteDefinition = this.resourceProvider.getSpriteDefinition();
assert(runnerSpriteDefinition);
// Multiple Horizon lines
for (let i = 0; i < runnerSpriteDefinition.lines.length; i++) {
this.horizonLines.push(new HorizonLine(
this.canvas, runnerSpriteDefinition.lines[i]!,
this.resourceProvider));
}
this.nightMode = new NightMode(
this.canvas, this.spritePos.moon, this.dimensions.height,
this.resourceProvider);
}
/**
* Update obstacle definitions based on the speed of the game.
*/
adjustObstacleSpeed() {
for (let i = 0; i < this.obstacleTypes.length; i++) {
if (this.resourceProvider.hasSlowdown) {
this.obstacleTypes[i]!.multipleSpeed =
this.obstacleTypes[i]!.multipleSpeed / 2;
this.obstacleTypes[i]!.minGap *= 1.5;
this.obstacleTypes[i]!.minSpeed = this.obstacleTypes[i]!.minSpeed / 2;
// Convert variable y position obstacles to fixed.
const obstacleYpos = this.obstacleTypes[i]!.yPos;
if (Array.isArray(obstacleYpos) && obstacleYpos.length > 1) {
this.obstacleTypes[i]!.yPos = obstacleYpos[0]!;
}
}
}
}
/**
* Update sprites to correspond to change in sprite sheet.
*/
enableAltGameMode(spritePos: SpritePositions) {
const runnerSpriteDefinition = this.resourceProvider.getSpriteDefinition();
assert(runnerSpriteDefinition);
// Clear existing horizon objects.
this.clouds = [];
this.backgroundEls = [];
this.altGameModeActive = true;
this.spritePos = spritePos;
this.obstacleTypes = runnerSpriteDefinition.obstacles;
this.adjustObstacleSpeed();
setMaxObstacleGapCoefficient(runnerSpriteDefinition.maxGapCoefficient);
setMaxObstacleLength(runnerSpriteDefinition.maxObstacleLength);
setBackgroundElGlobalConfig(runnerSpriteDefinition.backgroundElConfig);
this.horizonLines = [];
for (let i = 0; i < runnerSpriteDefinition.lines.length; i++) {
this.horizonLines.push(new HorizonLine(
this.canvas, runnerSpriteDefinition.lines[i]!,
this.resourceProvider));
}
this.reset();
}
/**
* @param updateObstacles Used as an override to prevent
* the obstacles from being updated / added. This happens in the
* ease in section.
* @param showNightMode Night mode activated.
*/
update(
deltaTime: number, currentSpeed: number, updateObstacles: boolean,
showNightMode: boolean) {
const runnerSpriteDefinition = this.resourceProvider.getSpriteDefinition();
assert(runnerSpriteDefinition);
if (this.altGameModeActive) {
this.updateBackgroundEls(deltaTime);
}
for (const line of this.horizonLines) {
line.update(deltaTime, currentSpeed);
}
if (!this.altGameModeActive || runnerSpriteDefinition.hasClouds) {
this.nightMode.update(showNightMode);
this.updateClouds(deltaTime, currentSpeed);
}
if (updateObstacles) {
this.updateObstacles(deltaTime, currentSpeed);
}
}
/**
* Update background element positions. Also handles creating new elements.
*/
private updateBackgroundEl(
elSpeed: number, bgElArray: Array<Cloud|BackgroundEl>, maxBgEl: number,
bgElAddFunction: () => void, frequency: number) {
const numElements = bgElArray.length;
if (!numElements) {
bgElAddFunction();
return;
}
for (let i = numElements - 1; i >= 0; i--) {
bgElArray[i]!.update(elSpeed);
}
const lastEl = bgElArray.at(-1)!;
// Check for adding a new element.
if (numElements < maxBgEl &&
(this.dimensions.width - lastEl.xPos) > lastEl.gap &&
frequency > Math.random()) {
bgElAddFunction();
}
}
/**
* Update the cloud positions.
*/
private updateClouds(deltaTime: number, speed: number) {
const elSpeed = this.cloudSpeed / 1000 * deltaTime * speed;
this.updateBackgroundEl(
elSpeed, this.clouds, this.config.MAX_CLOUDS, this.addCloud.bind(this),
this.cloudFrequency);
// Remove expired elements.
this.clouds = this.clouds.filter(obj => !obj.remove);
}
/**
* Update the background element positions.
*/
private updateBackgroundEls(deltaTime: number) {
this.updateBackgroundEl(
deltaTime, this.backgroundEls, getBackgroundElGlobalConfig().maxBgEls,
this.addBackgroundEl.bind(this), this.cloudFrequency);
// Remove expired elements.
this.backgroundEls = this.backgroundEls.filter(obj => !obj.remove);
}
/**
* Update the obstacle positions.
*/
private updateObstacles(deltaTime: number, currentSpeed: number) {
const updatedObstacles = this.obstacles.slice(0);
for (const obstacle of this.obstacles) {
obstacle.update(deltaTime, currentSpeed);
// Clean up existing obstacles.
if (obstacle.remove) {
updatedObstacles.shift();
}
}
this.obstacles = updatedObstacles;
if (this.obstacles.length > 0) {
const lastObstacle = this.obstacles.at(-1);
if (lastObstacle && !lastObstacle.followingObstacleCreated &&
lastObstacle.isVisible() &&
(lastObstacle.xPos + lastObstacle.width + lastObstacle.gap) <
this.dimensions.width) {
this.addNewObstacle(currentSpeed);
lastObstacle.followingObstacleCreated = true;
}
} else {
// Create new obstacles.
this.addNewObstacle(currentSpeed);
}
}
removeFirstObstacle() {
this.obstacles.shift();
}
/**
* Add a new obstacle.
*/
addNewObstacle(currentSpeed: number) {
const obstacleCount =
this.obstacleTypes[this.obstacleTypes.length - 1]!.type !==
'collectable' ||
(this.resourceProvider.isAltGameModeEnabled() &&
!this.altGameModeActive ||
this.altGameModeActive) ?
this.obstacleTypes.length - 1 :
this.obstacleTypes.length - 2;
const obstacleTypeIndex =
obstacleCount > 0 ? getRandomNum(0, obstacleCount) : 0;
const obstacleType = this.obstacleTypes[obstacleTypeIndex]!;
// Check for multiples of the same type of obstacle.
// Also check obstacle is available at current speed.
if ((obstacleCount > 0 && this.duplicateObstacleCheck(obstacleType.type)) ||
currentSpeed < obstacleType.minSpeed) {
this.addNewObstacle(currentSpeed);
} else {
const obstacleSpritePos = this.spritePos[obstacleType.type];
this.obstacles.push(new Obstacle(
this.canvasCtx, obstacleType, obstacleSpritePos, this.dimensions,
this.gapCoefficient, currentSpeed, obstacleType.width,
this.resourceProvider, this.altGameModeActive));
this.obstacleHistory.unshift(obstacleType.type);
if (this.obstacleHistory.length > 1) {
const maxObstacleDuplicationValue =
this.resourceProvider.getConfig().maxObstacleDuplication;
assert(maxObstacleDuplicationValue);
this.obstacleHistory.splice(maxObstacleDuplicationValue);
}
}
}
/**
* Returns whether the previous two obstacles are the same as the next one.
* Maximum duplication is set in config value MAX_OBSTACLE_DUPLICATION.
*/
duplicateObstacleCheck(nextObstacleType: keyof SpritePositions): boolean {
let duplicateCount = 0;
for (const obstacle of this.obstacleHistory) {
duplicateCount = obstacle === nextObstacleType ? duplicateCount + 1 : 0;
}
const maxObstacleDuplicationValue =
this.resourceProvider.getConfig().maxObstacleDuplication;
assert(maxObstacleDuplicationValue);
return duplicateCount >= maxObstacleDuplicationValue;
}
/**
* Reset the horizon layer.
* Remove existing obstacles and reposition the horizon line.
*/
reset() {
this.obstacles = [];
for (let l = 0; l < this.horizonLines.length; l++) {
this.horizonLines[l]!.reset();
}
this.nightMode.reset();
}
/**
* Update the canvas width and scaling.
*/
resize(width: number, height: number) {
this.canvas.width = width;
this.canvas.height = height;
}
/**
* Add a new cloud to the horizon.
*/
addCloud() {
this.clouds.push(new Cloud(
this.canvas, this.spritePos.cloud, this.dimensions.width,
this.resourceProvider));
}
/**
* Add a random background element to the horizon.
*/
addBackgroundEl() {
const runnerSpriteDefinition = this.resourceProvider.getSpriteDefinition();
assert(runnerSpriteDefinition);
const backgroundElTypes = Object.keys(runnerSpriteDefinition.backgroundEl);
if (backgroundElTypes.length > 0) {
let index = getRandomNum(0, backgroundElTypes.length - 1);
let type = backgroundElTypes[index]!;
// Add variation if available.
while (type === this.lastEl && backgroundElTypes.length > 1) {
index = getRandomNum(0, backgroundElTypes.length - 1);
type = backgroundElTypes[index]!;
}
this.lastEl = type;
this.backgroundEls.push(new BackgroundEl(
this.canvas, this.spritePos.backgroundEl, this.dimensions.width, type,
this.resourceProvider));
}
}
}
interface HorizonConfig {
BG_CLOUD_SPEED: number;
BUMPY_THRESHOLD: number;
CLOUD_FREQUENCY: number;
HORIZON_HEIGHT: number;
MAX_CLOUDS: number;
}
/**
* Horizon config.
*/
const horizonConfig: HorizonConfig = {
BG_CLOUD_SPEED: 0.2,
BUMPY_THRESHOLD: .3,
CLOUD_FREQUENCY: .5,
HORIZON_HEIGHT: 16,
MAX_CLOUDS: 6,
};