From 33db05d0f4c3bed99b01beddd4a432e0ec6a2430 Mon Sep 17 00:00:00 2001 From: keguigong Date: Sun, 19 Nov 2023 15:25:22 +0800 Subject: [PATCH 1/3] Add DistanceMeter --- game/Cloud.ts | 11 +-- game/DistanceMeter.ts | 207 ++++++++++++++++++++++++++++++++++++++++++ game/Horizon.ts | 17 +--- game/HorizonLine.ts | 20 ++-- game/Runner.ts | 87 ++++++++++-------- 5 files changed, 272 insertions(+), 70 deletions(-) create mode 100644 game/DistanceMeter.ts diff --git a/game/Cloud.ts b/game/Cloud.ts index acb8403..f21c097 100644 --- a/game/Cloud.ts +++ b/game/Cloud.ts @@ -7,20 +7,19 @@ export default class Cloud { spritePos!: Position containerWidth!: number - xPos!: number - yPos!: number - remove!: boolean - cloudGap!: number + xPos = Runner.defaultDimensions.WIDTH + yPos = 0 + remove = false + cloudGap = getRandomNum(Cloud.config.MIN_CLOUD_GAP, Cloud.config.MAX_CLOUD_GAP) constructor(canvas: HTMLCanvasElement, spritePos: Position, containerWidth: number) { this.canvas = canvas this.ctx = canvas.getContext("2d") as CanvasRenderingContext2D this.spritePos = spritePos this.containerWidth = containerWidth + this.xPos = containerWidth this.yPos = 0 - this.remove = false - this.cloudGap = getRandomNum(Cloud.config.MIN_CLOUD_GAP, Cloud.config.MAX_CLOUD_GAP) this.init() } diff --git a/game/DistanceMeter.ts b/game/DistanceMeter.ts new file mode 100644 index 0000000..7ee2305 --- /dev/null +++ b/game/DistanceMeter.ts @@ -0,0 +1,207 @@ +import Runner from "./Runner" +import { IS_HIDPI } from "./varibles" + +export default class DistanceMeter { + canvas!: HTMLCanvasElement + ctx!: CanvasRenderingContext2D + spritePos!: Position + + x = 0 + y = 0 + + distance = 0 + maxScore = 0 + highScore = "0" + + digits: string[] = [] // 存储分数的每一位数字 + achievement = false // 是否进行闪动特效 + defaultString = "" // 游戏的默认距离(00000) + flashTimer = 0 // 动画计时器 + flashIterations = 0 // 特效闪动的次数 + + config = DistanceMeter.config + maxScoreUnits = DistanceMeter.config.MAX_DISTANCE_UNITS // 分数的最大位数 + canvasWidth = 0 + + constructor(canvas: HTMLCanvasElement, spritePos: Position, canvasWidth: number) { + this.canvas = canvas + this.ctx = canvas.getContext("2d")! + this.spritePos = spritePos + this.canvasWidth = canvasWidth + + this.init(canvasWidth) + } + + /** + * Initialise the distance meter to '00000'. + */ + init(width: number) { + let maxDistanceStr = "" + + this.calcXPos(width) + for (let i = 0; i < this.maxScoreUnits; i++) { + this.draw(i, 0) + this.defaultString += "0" + maxDistanceStr += "9" + } + + this.maxScore = parseInt(maxDistanceStr) + } + + /** + * Calculate the xPos in the canvas. + */ + calcXPos(canvasWidth: number) { + this.x = canvasWidth - DistanceMeter.dimensions.DEST_WIDTH * (this.maxScoreUnits + 1) + } + + draw(digitPos: number, value: number, optHighScore?: boolean) { + let sourceWidth = DistanceMeter.dimensions.WIDTH + let sourceHeight = DistanceMeter.dimensions.HEIGHT + let sourceX = DistanceMeter.dimensions.WIDTH * value + let sourceY = 0 + + let targetX = digitPos * DistanceMeter.dimensions.DEST_WIDTH + let targetY = this.y + let targetWidth = DistanceMeter.dimensions.WIDTH + let targetHeight = DistanceMeter.dimensions.HEIGHT + + // For high DPI we 2x source values. + if (IS_HIDPI) { + sourceWidth *= 2 + sourceHeight *= 2 + sourceX *= 2 + } + + sourceX += this.spritePos.x + sourceY += this.spritePos.y + + this.ctx.save() + + let highScoreX = this.x - this.maxScoreUnits * 2 * DistanceMeter.dimensions.WIDTH + + if (optHighScore) { + this.ctx.translate(highScoreX, this.y) + } else { + this.ctx.translate(this.x, this.y) + } + + this.ctx.drawImage( + Runner.imageSprite, + sourceX, + sourceY, + sourceWidth, + sourceHeight, + targetX, + targetY, + targetWidth, + targetHeight + ) + + this.ctx.restore() + } + + /** + * Covert pixel distance to a 'real' distance. + */ + getActualDistance(distance: number) { + return distance ? Math.round(distance * this.config.COEFFICIENT) : 0 + } + + /** + * Update the distance meter. + */ + update(deltaTime: number, distance: number) { + let paint = true + let playSound = false + + if (!this.achievement) { + distance = this.getActualDistance(distance) + // Score has gone beyond the initial digit count. + if (distance > this.maxScore && this.maxScoreUnits == this.config.MAX_DISTANCE_UNITS) { + this.maxScoreUnits++ + this.maxScore = parseInt(this.maxScore + "9") + } else { + this.distance = 0 + } + + if (distance > 0) { + // Achievement unlocked. + if (distance % this.config.ACHIEVEMENT_DISTANCE == 0) { + // Flash score and play sound. + this.achievement = true + this.flashTimer = 0 + playSound = true + } + // Create a string representation of the distance with leading 0. + let distanceStr = (this.defaultString + distance).substr(-this.maxScoreUnits) + this.digits = distanceStr.split("") + } + } else { + // Control flashing of the score on reaching acheivement. + if (this.flashIterations <= this.config.FLASH_ITERATIONS) { + this.flashTimer += deltaTime + + if (this.flashTimer < this.config.FLASH_DURATION) { + paint = false + } else if (this.flashTimer > this.config.FLASH_DURATION * 2) { + this.flashTimer = 0 + this.flashIterations++ + } + } else { + this.achievement = false + this.flashIterations = 0 + this.flashTimer = 0 + } + } + + // Draw the digits if not flashing. + if (paint) { + for (let i = this.digits.length - 1; i >= 0; i--) { + this.draw(i, parseInt(this.digits[i])) + } + } + + this.drawHighScore() + return playSound + } + + /** + * Draw the high score. + */ + drawHighScore() { + if (parseInt(this.highScore, 16) > 0) { + this.ctx.save() + this.ctx.globalAlpha = 0.8 + for (let i = this.highScore.length - 1; i >= 0; i--) { + this.draw(i, parseInt(this.highScore[i], 16), true) + } + this.ctx.restore() + } + } + + /** + * Set the highscore as a array string. + * Position of char in the sprite: H - 10, I - 11. + */ + setHightScore(distance: number) { + distance = this.getActualDistance(distance) + let highScoreStr = (this.defaultString + distance).substr(-this.maxScoreUnits) + + this.highScore = "AB " + highScoreStr + } + + static config = { + MAX_DISTANCE_UNITS: 5, // 分数的最大位数 + ACHIEVEMENT_DISTANCE: 100, // 每 100 米触发一次闪动特效 + COEFFICIENT: 0.025, // 将像素距离转换为比例单位的系数 + FLASH_DURATION: 1000 / 4, // 一闪的时间(一次闪动分别两闪:从有到无,从无到有) + FLASH_ITERATIONS: 3 // 闪动的次数 + } + + static dimensions = { + WIDTH: 10, + HEIGHT: 13, + DEST_WIDTH: 11 // 加上间隔后每个数字的宽度 + } +} diff --git a/game/Horizon.ts b/game/Horizon.ts index e870354..1128034 100644 --- a/game/Horizon.ts +++ b/game/Horizon.ts @@ -12,13 +12,13 @@ export default class Horizon { horizonLine!: HorizonLine dimensions!: Dimensions - cloudFrequency!: number - clouds!: Cloud[] - cloudSpeed!: number + cloudFrequency = Cloud.config.CLOUD_FREQUENCY + cloudSpeed = Cloud.config.BG_CLOUD_SPEED + clouds: Cloud[] = [] gapCoeffecient!: number - obstacles!: Obstacle[] - obstacleHistory!: string[] + obstacles: Obstacle[] = [] + obstacleHistory: string[] = [] constructor(canvas: HTMLCanvasElement, spritePos: SpritePosDef, dimensions: Dimensions, gapCoeffient: number) { this.canvas = canvas @@ -27,13 +27,6 @@ export default class Horizon { this.dimensions = dimensions this.gapCoeffecient = gapCoeffient - this.obstacles = [] - this.obstacleHistory = [] - - this.cloudFrequency = Cloud.config.CLOUD_FREQUENCY - this.cloudSpeed = Cloud.config.BG_CLOUD_SPEED - this.clouds = [] - this.init() } diff --git a/game/HorizonLine.ts b/game/HorizonLine.ts index 1ad5ffc..bbfa89d 100644 --- a/game/HorizonLine.ts +++ b/game/HorizonLine.ts @@ -5,25 +5,19 @@ export default class HorizonLine { canvas!: HTMLCanvasElement ctx!: CanvasRenderingContext2D - sourceDimensions!: Dimensions - dimensions!: Dimensions + sourceDimensions: Dimensions = { ...HorizonLine.dimensions } + dimensions: Dimensions = HorizonLine.dimensions + spritePos!: Position - sourceXPos!: number[] - xPos!: number[] - yPos!: number - bumpThreshold!: number + sourceXPos = [0, HorizonLine.dimensions.WIDTH] + xPos: number[] = [] + yPos = 0 + bumpThreshold = 0.5 constructor(canvas: HTMLCanvasElement, spritePos: Position) { this.canvas = canvas this.ctx = canvas.getContext("2d") as CanvasRenderingContext2D - this.sourceDimensions = { ...HorizonLine.dimensions } - this.dimensions = HorizonLine.dimensions - this.spritePos = spritePos - this.sourceXPos = [0, HorizonLine.dimensions.WIDTH] - this.xPos = [] - this.yPos = 0 - this.bumpThreshold = 0.5 this.setSourceDimensions() this.draw() diff --git a/game/Runner.ts b/game/Runner.ts index 273b4d7..1fa3c95 100644 --- a/game/Runner.ts +++ b/game/Runner.ts @@ -1,7 +1,8 @@ import CollisionBox from "./CollisionBox" +import DistanceMeter from "./DistanceMeter" import Horizon from "./Horizon" import Trex from "./Trex" -import { IS_HIDPI, IS_MOBILE, RESOURCE_POSTFIX } from "./varibles" +import { FPS, IS_HIDPI, IS_MOBILE, RESOURCE_POSTFIX } from "./varibles" const DEFAULT_WIDTH = 600 export default class Runner { @@ -10,46 +11,36 @@ export default class Runner { outerContainerEl!: HTMLElement containerEl!: HTMLElement - config!: ConfigDict - dimensions!: Dimensions + config: ConfigDict = Runner.config + dimensions = Runner.defaultDimensions canvas!: HTMLCanvasElement ctx!: CanvasRenderingContext2D - time!: number - runningTime!: number - currentSpeed!: number + time = Date.now() + runningTime = 0 + currentSpeed = Runner.config.SPEED - activated!: boolean - playing!: boolean - crashed!: boolean - paused!: boolean - updatePending!: boolean - resizeTimerId_!: NodeJS.Timer | null + activated = false + playing = false + crashed = false + paused = false + updatePending = false + resizeTimerId_: NodeJS.Timer | null = null - raqId!: number + raqId = 0 horizon!: Horizon playingIntro!: boolean - constructor(outerContainerId: string, optConfig?: ConfigDict) { - this.outerContainerEl = document.querySelector(outerContainerId) as HTMLElement + msPerFrame = 1000 / FPS + distanceMeter!: DistanceMeter + distanceRan = 0 + highestScore = 0 + constructor(outerContainerId: string, optConfig?: ConfigDict) { + this.outerContainerEl = document.querySelector(outerContainerId)! this.config = optConfig || Runner.config - this.dimensions = Runner.defaultDimensions - - this.time = Date.now() - this.runningTime = 0 - this.currentSpeed = Runner.config.SPEED - - this.activated = false - this.playing = false - this.crashed = false - this.paused = false - this.updatePending = false - this.resizeTimerId_ = null - - this.raqId = 0 this.loadImages() } @@ -91,10 +82,12 @@ export default class Runner { this.ctx.fillStyle = "#f7f7f7" this.ctx.fill() Runner.updateCanvasScaling(this.canvas) + this.outerContainerEl.appendChild(this.containerEl) + // Load background class Horizon this.horizon = new Horizon(this.canvas, this.spriteDef, this.dimensions, Runner.config.GAP_COEFFICIENT) - this.outerContainerEl.appendChild(this.containerEl) + this.distanceMeter = new DistanceMeter(this.canvas, this.spriteDef.TEXT_SPRITE, this.dimensions.WIDTH) this.startListening() this.update() @@ -176,6 +169,9 @@ export default class Runner { if (this.currentSpeed < Runner.config.MAX_SPEED) { this.currentSpeed += Runner.config.ACCELERATION } + + this.distanceRan += (this.currentSpeed * deltaTime) / this.msPerFrame + let playAchievementSound = this.distanceMeter?.update(deltaTime, Math.ceil(this.distanceRan)) } if (this.playing) { @@ -232,6 +228,26 @@ export default class Runner { window.addEventListener(Runner.events.FOCUS, this.onVisibilityChange.bind(this)) } + restart() { + if (!this.raqId) { + this.runningTime = 0 + this.setPlayStatus(true) + this.paused = false + this.crashed = false + } + } + + gameOver() { + this.stop() + + if (this.distanceRan > this.highestScore) { + this.highestScore = Math.ceil(this.distanceRan) + this.distanceMeter.setHightScore(this.highestScore) + } + + this.time = Date.now() + } + clearCanvas() { this.ctx.clearRect(0, 0, this.dimensions.WIDTH, this.dimensions.HEIGHT) } @@ -269,19 +285,12 @@ export default class Runner { this.containerEl.style.transform = "scale(" + scale + ") translateY(" + translateY + "px)" } - restart() { - if (!this.raqId) { - this.runningTime = 0 - this.setPlayStatus(true) - this.paused = false - this.crashed = false - } - } - onVisibilityChange(e: Event) { console.log(e.type) if (document.hidden || e.type === Runner.events.BLUR || document.visibilityState != "visible") { this.stop() + + this.gameOver() } else if (!this.crashed) { this.play() } From 576cf8edb2382f496a3aae91f253c690fb358d9d Mon Sep 17 00:00:00 2001 From: keguigong Date: Sun, 19 Nov 2023 17:50:39 +0800 Subject: [PATCH 2/3] Add DistanceMeter and NightMode --- game/Horizon.ts | 7 +- game/NightMode.ts | 163 +++++++++++++++++++++++++++++++++++++++++++++ game/Runner.ts | 100 +++++++++++++++++++++------ styles/globals.css | 10 +++ 4 files changed, 260 insertions(+), 20 deletions(-) create mode 100644 game/NightMode.ts diff --git a/game/Horizon.ts b/game/Horizon.ts index 1128034..27eef7e 100644 --- a/game/Horizon.ts +++ b/game/Horizon.ts @@ -1,5 +1,6 @@ import Cloud from "./Cloud" import HorizonLine from "./HorizonLine" +import NightMode from "./NightMode" import Obstacle from "./Obstacle" import Runner from "./Runner" import { getRandomNum } from "./varibles" @@ -20,6 +21,8 @@ export default class Horizon { obstacles: Obstacle[] = [] obstacleHistory: string[] = [] + nightMode!: NightMode + constructor(canvas: HTMLCanvasElement, spritePos: SpritePosDef, dimensions: Dimensions, gapCoeffient: number) { this.canvas = canvas this.ctx = canvas.getContext("2d") as CanvasRenderingContext2D @@ -33,10 +36,12 @@ export default class Horizon { private init() { this.addCloud() this.horizonLine = new HorizonLine(this.canvas, this.spritePos.HORIZON) + this.nightMode = new NightMode(this.canvas, this.spritePos.MOON, this.dimensions.WIDTH) } - update(deltaTime: number, speed: number, hasObstacles?: boolean) { + update(deltaTime: number, speed: number, hasObstacles?: boolean, showNightMode: boolean = false) { this.horizonLine.update(deltaTime, speed) + this.nightMode.update(showNightMode) this.updateCloud(deltaTime, speed) if (hasObstacles) { this.updateObstacles(deltaTime, speed) diff --git a/game/NightMode.ts b/game/NightMode.ts new file mode 100644 index 0000000..68727a5 --- /dev/null +++ b/game/NightMode.ts @@ -0,0 +1,163 @@ +import Runner from "./Runner" +import { IS_HIDPI, getRandomNum } from "./varibles" + +export default class NightMode { + canvas!: HTMLCanvasElement + ctx!: CanvasRenderingContext2D + + spritePos!: Position + containerWidth!: number + + xPos = 0 + yPos = 30 + currentPhase = 0 + opacity = 0 + stars: any[] = [] + drawStars = false + + constructor(canvas: HTMLCanvasElement, spritePos: Position, containerWidth: number) { + this.canvas = canvas + this.ctx = canvas.getContext("2d")! + + this.spritePos = spritePos + this.containerWidth = containerWidth + + this.xPos = containerWidth - 50 + this.yPos = 30 + + this.placeStars() + } + + update(activated: boolean) { + // Moon phase. + if (activated && this.opacity === 0) { + this.currentPhase++ + + if (this.currentPhase >= NightMode.phases.length) { + this.currentPhase = 0 + } + } + + // Fade in / out. + if (activated && (this.opacity < 1 || this.opacity === 0)) { + this.opacity += NightMode.config.FADE_SPEED + } else if (this.opacity > 0) { + this.opacity -= NightMode.config.FADE_SPEED + } + + // Set moon positioning. + if (this.opacity > 0) { + this.xPos = this.updateXPos(this.xPos, NightMode.config.MOON_SPEED) + + // Update stars. + if (this.drawStars) { + for (let i = 0; i < NightMode.config.NUM_STARS; i++) { + this.stars[i].x = this.updateXPos(this.stars[i].x, NightMode.config.STAR_SPEED) + } + } + this.draw() + } else { + this.opacity = 0 + this.placeStars() + } + this.drawStars = true + } + + updateXPos(currentPos: number, speed: number) { + if (currentPos < -NightMode.config.WIDTH) { + currentPos = this.containerWidth + } else { + currentPos -= speed + } + return currentPos + } + + draw() { + let moonSourceWidth = this.currentPhase === 3 ? NightMode.config.WIDTH * 2 : NightMode.config.WIDTH + let moonSourceHeight = NightMode.config.HEIGHT + let moonSourceX = this.spritePos.x + NightMode.phases[this.currentPhase] + const moonOutputWidth = moonSourceWidth + let starSize = NightMode.config.STAR_SIZE + let starSourceX = Runner.spriteDefinition.LDPI.STAR.x + + if (IS_HIDPI) { + moonSourceWidth *= 2 + moonSourceHeight *= 2 + moonSourceX = this.spritePos.x + NightMode.phases[this.currentPhase] * 2 + starSize *= 2 + starSourceX = Runner.spriteDefinition.HDPI.STAR.x + } + + this.ctx.save() + this.ctx.globalAlpha = this.opacity + + // Stars. + if (this.drawStars) { + for (let i = 0; i < NightMode.config.NUM_STARS; i++) { + this.ctx.drawImage( + Runner.imageSprite, + starSourceX, + this.stars[i].sourceY, + starSize, + starSize, + Math.round(this.stars[i].x), + this.stars[i].y, + NightMode.config.STAR_SIZE, + NightMode.config.STAR_SIZE + ) + } + } + + // Moon. + this.ctx.drawImage( + Runner.imageSprite, + moonSourceX, + this.spritePos.y, + moonSourceWidth, + moonSourceHeight, + Math.round(this.xPos), + this.yPos, + moonOutputWidth, + NightMode.config.HEIGHT + ) + + this.ctx.globalAlpha = 1 + this.ctx.restore() + } + + placeStars() { + const segmentSize = Math.round(this.containerWidth / NightMode.config.NUM_STARS) + + for (let i = 0; i < NightMode.config.NUM_STARS; i++) { + this.stars[i] = {} + this.stars[i].x = getRandomNum(segmentSize * i, segmentSize * (i + 1)) + this.stars[i].y = getRandomNum(0, NightMode.config.STAR_MAX_Y) + + if (IS_HIDPI) { + this.stars[i].sourceY = Runner.spriteDefinition.HDPI.STAR.y + NightMode.config.STAR_SIZE * 2 * i + } else { + this.stars[i].sourceY = Runner.spriteDefinition.LDPI.STAR.y + NightMode.config.STAR_SIZE * i + } + } + } + + reset() { + this.currentPhase = 0 + this.opacity = 0 + this.update(false) + } + + static config = { + WIDTH: 20, // 半月的宽度 + HEIGHT: 40, // 月亮的高度 + FADE_SPEED: 0.035, // 淡入淡出的速度 + MOON_SPEED: 0.25, // 月亮的速度 + NUM_STARS: 2, // 星星的数量 + STAR_SIZE: 9, // 星星的大小 + STAR_SPEED: 0.3, // 星星的速度 + STAR_MAX_Y: 70 // 星星在画布上的最大 y 坐标 + } + + // 月亮所处的时期(不同的时期有不同的位置) + static phases = [140, 120, 100, 60, 40, 20, 0] +} diff --git a/game/Runner.ts b/game/Runner.ts index 1fa3c95..0de9766 100644 --- a/game/Runner.ts +++ b/game/Runner.ts @@ -25,6 +25,9 @@ export default class Runner { playing = false crashed = false paused = false + inverted = false // Night mode on + inverTimer = 0 // Night mode start time + invertTrigger = false updatePending = false resizeTimerId_: NodeJS.Timer | null = null @@ -40,7 +43,7 @@ export default class Runner { constructor(outerContainerId: string, optConfig?: ConfigDict) { this.outerContainerEl = document.querySelector(outerContainerId)! - this.config = optConfig || Runner.config + this.config = optConfig || Object.assign(Runner.config, Runner.normalConfig) this.loadImages() } @@ -85,7 +88,7 @@ export default class Runner { this.outerContainerEl.appendChild(this.containerEl) // Load background class Horizon - this.horizon = new Horizon(this.canvas, this.spriteDef, this.dimensions, Runner.config.GAP_COEFFICIENT) + this.horizon = new Horizon(this.canvas, this.spriteDef, this.dimensions, this.config.GAP_COEFFICIENT) this.distanceMeter = new DistanceMeter(this.canvas, this.spriteDef.TEXT_SPRITE, this.dimensions.WIDTH) @@ -159,19 +162,45 @@ export default class Runner { this.playIntro() } + // The horizon doesn't move until the intro is over. if (this.playingIntro) { this.horizon.update(0, this.currentSpeed, hasObstacles) - } else { + } else if (!this.crashed) { deltaTime = !this.activated ? 0 : deltaTime - this.horizon.update(deltaTime, this.currentSpeed, hasObstacles) + this.horizon.update(deltaTime, this.currentSpeed, hasObstacles, this.inverted) } - if (this.currentSpeed < Runner.config.MAX_SPEED) { - this.currentSpeed += Runner.config.ACCELERATION + if (this.currentSpeed < this.config.MAX_SPEED) { + this.currentSpeed += this.config.ACCELERATION } this.distanceRan += (this.currentSpeed * deltaTime) / this.msPerFrame - let playAchievementSound = this.distanceMeter?.update(deltaTime, Math.ceil(this.distanceRan)) + let playAchievementSound = this.distanceMeter.update(deltaTime, Math.ceil(this.distanceRan)) + } + + // Night mode. + if (this.inverTimer > this.config.INVERT_FADE_DURATION) { + // 夜晚模式结束 + this.inverTimer = 0 + this.invertTrigger = false + this.invert(false) + } else if (this.inverTimer) { + // 处于夜晚模式,更新其时间 + this.inverTimer += deltaTime + } else { + // 还没进入夜晚模式 + // 游戏移动的距离 + const actualDistance = this.distanceMeter.getActualDistance(Math.ceil(this.distanceRan)) + + if (actualDistance > 0) { + // 每移动指定距离就触发一次夜晚模式 + this.invertTrigger = !(actualDistance % this.config.INVERT_DISTANCE) + + if (this.invertTrigger && this.inverTimer === 0) { + this.inverTimer += deltaTime + this.invert(false) + } + } } if (this.playing) { @@ -285,11 +314,26 @@ export default class Runner { this.containerEl.style.transform = "scale(" + scale + ") translateY(" + translateY + "px)" } + /** + * Inverts the current page / canvas colors. + */ + invert(reset: boolean) { + const htmlEl = document.firstElementChild + + if (reset) { + htmlEl?.classList.toggle(Runner.classes.INVERTED, false) + this.inverTimer = 0 + this.inverted = false + } else { + this.inverted = htmlEl?.classList.toggle(Runner.classes.INVERTED, this.invertTrigger)! + } + } + onVisibilityChange(e: Event) { console.log(e.type) if (document.hidden || e.type === Runner.events.BLUR || document.visibilityState != "visible") { this.stop() - + this.gameOver() } else if (!this.crashed) { this.play() @@ -442,29 +486,47 @@ export default class Runner { RESTART: { Enter: 1 } as any // Enter } + /** + * Default game configuration. + * Shared config for all versions of the game. Additional parameters are + * defined in Runner.normalConfig and Runner.slowConfig. + */ static config = { - SPEED: 6, + AUDIOCUE_PROXIMITY_THRESHOLD: 190, + AUDIOCUE_PROXIMITY_THRESHOLD_MOBILE_A11Y: 250, BG_CLOUD_SPEED: 0.2, + BOTTOM_PAD: 10, + // Scroll Y threshold at which the game can be activated. + CANVAS_IN_VIEW_OFFSET: -10, + CLEAR_TIME: 3000, CLOUD_FREQUENCY: 0.5, - GAP_COEFFICIENT: 0.6, + FADE_DURATION: 1, + FLASH_DURATION: 1000, + GAMEOVER_CLEAR_TIME: 1200, + INITIAL_JUMP_VELOCITY: 12, + INVERT_FADE_DURATION: 12000, + MAX_BLINK_COUNT: 3, MAX_CLOUDS: 6, - MAX_SPEED: 12, MAX_OBSTACLE_LENGTH: 3, MAX_OBSTACLE_DUPLICATION: 2, - CLEAR_TIME: 3000, - ACCELERATION: 0.001, - BOTTOM_PAD: 10, - GAMEOVER_CLEAR_TIME: 750, - GRAVITY: 0.6, - INITIAL_JUMP_VELOCITY: 12, - MIN_JUMP_HEIGHT: 35, - MOBILE_SPEED_COEFFICIENT: 1.2, RESOURCE_TEMPLATE_ID: "audio-resources", + SPEED: 6, SPEED_DROP_COEFFICIENT: 3, ARCADE_MODE_INITIAL_TOP_POSITION: 35, ARCADE_MODE_TOP_POSITION_PERCENT: 0.1 } + static normalConfig = { + ACCELERATION: 0.001, + AUDIOCUE_PROXIMITY_THRESHOLD: 190, + AUDIOCUE_PROXIMITY_THRESHOLD_MOBILE_A11Y: 250, + GAP_COEFFICIENT: 0.6, + INVERT_DISTANCE: 100, + MAX_SPEED: 13, + MOBILE_SPEED_COEFFICIENT: 1.2, + SPEED: 6 + } + static imageSprite: HTMLImageElement static spriteDefinition = { diff --git a/styles/globals.css b/styles/globals.css index 3ab90c0..f44a2ec 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -1,3 +1,13 @@ +html { + transition: filter 1.5s cubic-bezier(0.65, 0.05, 0.36, 1), background-color 1.5s cubic-bezier(0.65, 0.05, 0.36, 1); + will-change: filter, background-color; +} + +.inverted { + filter: invert(100%); + background-color: #fff; +} + .interstitial-wrapper { font-size: 1em; line-height: 1.55; From 35bc525c002c5fb8674bd4557314ea5fb0be5300 Mon Sep 17 00:00:00 2001 From: keguigong Date: Mon, 20 Nov 2023 01:46:13 +0800 Subject: [PATCH 3/3] Add T-Rex --- game/Runner.ts | 57 ++++++++-- game/Trex.ts | 278 +++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 296 insertions(+), 39 deletions(-) diff --git a/game/Runner.ts b/game/Runner.ts index 0de9766..8337c28 100644 --- a/game/Runner.ts +++ b/game/Runner.ts @@ -41,6 +41,8 @@ export default class Runner { distanceRan = 0 highestScore = 0 + tRex!: Trex + constructor(outerContainerId: string, optConfig?: ConfigDict) { this.outerContainerEl = document.querySelector(outerContainerId)! this.config = optConfig || Object.assign(Runner.config, Runner.normalConfig) @@ -91,6 +93,7 @@ export default class Runner { this.horizon = new Horizon(this.canvas, this.spriteDef, this.dimensions, this.config.GAP_COEFFICIENT) this.distanceMeter = new DistanceMeter(this.canvas, this.spriteDef.TEXT_SPRITE, this.dimensions.WIDTH) + this.tRex = new Trex(this.canvas, this.spriteDef.TREX) this.startListening() this.update() @@ -155,10 +158,14 @@ export default class Runner { if (this.playing) { this.clearCanvas() + if (this.tRex.jumping) { + this.tRex.updateJump(deltaTime) + } + this.runningTime += deltaTime let hasObstacles = this.runningTime > Runner.config.CLEAR_TIME - if (!this.playingIntro) { + if (!this.playingIntro && this.tRex.jumpCount === 1) { this.playIntro() } @@ -203,7 +210,9 @@ export default class Runner { } } - if (this.playing) { + if (this.playing || (!this.activated && this.tRex.blinkCount < Runner.config.MAX_BLINK_COUNT)) { + this.tRex.update(deltaTime) + this.scheduleNextUpdate() } } @@ -225,6 +234,7 @@ export default class Runner { playIntro() { if (!this.activated && !this.crashed) { this.playingIntro = true + this.tRex.playingIntro = true let keyframes = `@-webkit-keyframes intro { from { width: ${Trex.config.WIDTH}px } @@ -249,6 +259,8 @@ export default class Runner { if (this.isArcadeMode()) { this.setArcadeMode() } + + this.tRex.playingIntro = false this.runningTime = 0 this.playingIntro = false this.containerEl.style.webkitAnimation = "" @@ -363,27 +375,53 @@ export default class Runner { handleEvent = (e: KeyboardEvent) => { const evtType = e.type switch (evtType) { - case Runner.events.KEYDOWN: - case Runner.events.TOUCHSTART: case Runner.events.KEYDOWN: this.onKeydown(e) break + case Runner.events.KEYUP: + this.onKeyUp(e) + break } } onKeydown(e: KeyboardEvent) { if (!this.crashed && !this.paused) { - if (Runner.keycodes.JUMP[e.code]) { + if (Runner.keycodes.JUMP[e.keyCode]) { e.preventDefault() if (!this.playing) { this.setPlayStatus(true) this.update() } + + if (!this.tRex.jumping && !this.tRex.ducking) { + this.tRex.startJump(this.currentSpeed) + } + } else if (this.playing && Runner.keycodes.DUCK[e.keyCode]) { + e.preventDefault() + + if (this.tRex.jumping) { + this.tRex.setSpeedDrop() + } else if (!this.tRex.jumping && !this.tRex.ducking) { + this.tRex.setDuck(true) + } } } } + onKeyUp(e: KeyboardEvent) { + if (this.isRunning() && Runner.keycodes.JUMP[e.keyCode]) { + this.tRex.endJump() + } else if (Runner.keycodes.DUCK[e.keyCode]) { + this.tRex.speedDrop = false + this.tRex.setDuck(false) + } + } + + isRunning() { + return !!this.raqId + } + stop() { this.setPlayStatus(false) this.paused = true @@ -397,6 +435,7 @@ export default class Runner { this.paused = false this.time = Date.now() this.update() + this.tRex.reset() } } @@ -481,9 +520,9 @@ export default class Runner { } static keycodes = { - JUMP: { ArrowUp: 1, Space: 1 } as any, // Up, spacebar - DUCK: { ArrowDown: 1 } as any, // Down - RESTART: { Enter: 1 } as any // Enter + JUMP: { 38: 1, 32: 1 } as any, // Up, spacebar + DUCK: { 40: 1 } as any, // Down + RESTART: { 13: 1 } as any // Enter } /** @@ -521,7 +560,7 @@ export default class Runner { AUDIOCUE_PROXIMITY_THRESHOLD: 190, AUDIOCUE_PROXIMITY_THRESHOLD_MOBILE_A11Y: 250, GAP_COEFFICIENT: 0.6, - INVERT_DISTANCE: 100, + INVERT_DISTANCE: 700, MAX_SPEED: 13, MOBILE_SPEED_COEFFICIENT: 1.2, SPEED: 6 diff --git a/game/Trex.ts b/game/Trex.ts index bcb4e99..48ff9cb 100644 --- a/game/Trex.ts +++ b/game/Trex.ts @@ -1,43 +1,267 @@ +import Runner from "./Runner" +import { FPS, IS_HIDPI } from "./varibles" + export default class Trex { - spriteImage!: CanvasImageSource + canvas!: HTMLCanvasElement ctx!: CanvasRenderingContext2D + spritePos!: Position xPos = 0 yPos = 0 + xInitialPos = 0 + groundYPos = 0 + minJumpHeight = 0 + + currentFrame = 0 + currentAnimFrams = [] + blinkDelay = 0 + blinkCount = 0 + animStartTime = 0 + timer = 0 + msPerFrame = 1000 / FPS + status = Trex.status.WAITING + config = Object.assign(Trex.config, Trex.normalJumpConfig) + + jumping = false + ducking = false + jumpVelocity = 0 + reachedMinHeight = false + speedDrop = false + jumpCount = 0 + jumpSpotX = 0 + + playingIntro = false + + constructor(canvas: HTMLCanvasElement, spritePos: Position) { + this.canvas = canvas + this.ctx = canvas.getContext("2d")! + this.spritePos = spritePos + + this.init() + } + + init() { + this.groundYPos = Runner.defaultDimensions.HEIGHT - this.config.HEIGHT - Runner.config.BOTTOM_PAD + this.yPos = this.groundYPos + this.minJumpHeight = this.groundYPos - this.config.MIN_JUMP_HEIGHT + + this.update(0, Trex.status.WAITING) + this.draw(0, 0) + } + + /** + * Set the animation status. + */ + update(deltaTime: number, optStatus?: string) { + this.timer += deltaTime + if (optStatus) { + this.status = optStatus + this.currentFrame = 0 + this.msPerFrame = Trex.animFrames[optStatus].msPerFrame + this.currentAnimFrams = Trex.animFrames[optStatus].frames + + if (optStatus === Trex.status.WAITING) { + this.animStartTime = Date.now() + this.setBlinkDelay() + } + } + + // Game intro animation, T-rex moves in from the left. + if (this.playingIntro && this.xPos < this.config.START_X_POS) { + this.xPos += Math.round((this.config.START_X_POS / this.config.INTRO_DURATION) * deltaTime) + this.xInitialPos = this.xPos + } + + if (this.status === Trex.status.WAITING) { + this.blink(Date.now()) + } else { + this.draw(this.currentAnimFrams[this.currentFrame], 0) + } + + if (this.timer >= this.msPerFrame) { + this.currentFrame = this.currentFrame === this.currentAnimFrams.length - 1 ? 0 : this.currentFrame + 1 + this.timer = 0 + } + } - private constructor(ctx: CanvasRenderingContext2D, spriteImage: CanvasImageSource) { - this.ctx = ctx - this.spriteImage = spriteImage + setBlinkDelay() { + this.blinkDelay = Math.ceil(Math.random() * Trex.BLINK_TIMING) + } + + /** + * Make t-rex blink at random intervals. + */ + blink(time: number) { + const deltaTime = time - this.animStartTime + + if (deltaTime >= this.blinkDelay) { + this.draw(this.currentAnimFrams[this.currentFrame], 0) + + if (this.currentFrame === 1) { + // Set new random delay to blink. + this.setBlinkDelay() + this.animStartTime = time + this.blinkCount++ + } + } } draw(x: number, y: number) { - let sx = x - let sy = y - let sw = Trex.config.WIDTH - let sh = Trex.config.HEIGHT - if (true) { - sx *= 2 - sy *= 2 - sw *= 2 - sh *= 2 + let sourceX = x + let sourceY = y + let sourceWidth = this.ducking && this.status !== Trex.status.CRASHED ? this.config.WIDTH_DUCK : this.config.WIDTH + let sourceHeight = this.config.HEIGHT + const outputHeight = sourceHeight + + let jumpOffset = Runner.spriteDefinition.TREX.JUMPING.xOffset + + if (IS_HIDPI) { + sourceX *= 2 + sourceY *= 2 + sourceWidth *= 2 + sourceHeight *= 2 + jumpOffset *= 2 } - this.ctx.drawImage(this.spriteImage, sx, sy, sw, sh, this.xPos, this.yPos, Trex.config.WIDTH, Trex.config.HEIGHT) + + // Adjustments for sprite sheet position. + sourceX += this.spritePos.x + sourceY += this.spritePos.y + + if (this.ducking && this.status !== Trex.status.CRASHED) { + this.ctx.drawImage( + Runner.imageSprite, + sourceX, + sourceY, + sourceWidth, + sourceHeight, + this.xPos, + this.yPos, + this.config.WIDTH_DUCK, + outputHeight + ) + } else { + if (this.ducking && this.status == Trex.status.CRASHED) { + this.xPos++ + } + + this.ctx.drawImage( + Runner.imageSprite, + sourceX, + sourceY, + sourceWidth, + sourceHeight, + this.xPos, + this.yPos, + this.config.WIDTH, + outputHeight + ) + } + this.ctx.globalAlpha = 1 + } + + startJump(speed: number) { + if (!this.jumping) { + this.update(0, Trex.status.JUMPING) + + this.jumpVelocity = this.config.INITIAL_JUMP_VELOCITY - speed / 10 + this.jumping = true + this.reachedMinHeight = false + this.speedDrop = false + } + } + + endJump() { + if (this.reachedMinHeight && this.jumpVelocity < this.config.DROP_VELOCITY) { + this.jumpVelocity = this.config.DROP_VELOCITY + } + } + + updateJump(deltaTime: number) { + const msPerFrame = Trex.animFrames[this.status].msPerFrame + const frameElapsed = deltaTime / msPerFrame + + // Speed drop makes Trex fall faster. + if (this.speedDrop) { + this.yPos += Math.round(this.jumpVelocity * this.config.SPEED_DROP_COEFFICIENT * frameElapsed) + } else { + this.yPos += Math.round(this.jumpVelocity * frameElapsed) + } + + this.jumpVelocity += this.config.GRAVITY * frameElapsed + + // Minimum height has been reached. + if (this.yPos < this.minJumpHeight || this.speedDrop) { + this.reachedMinHeight = true + } + + // Reached max height. + if (this.yPos < this.config.MAX_JUMP_HEIGHT || this.speedDrop) { + this.endJump() + } + + // Back down at ground level. Jump completed. + if (this.yPos > this.groundYPos) { + this.reset() + this.jumpCount++ + } + } + + setSpeedDrop() { + this.speedDrop = true + this.jumpVelocity = 1 + } + + setDuck(isDucking: boolean) { + if (isDucking && this.status !== Trex.status.DUCKING) { + this.update(0, Trex.status.DUCKING) + this.ducking = true + } else if (this.status === Trex.status.DUCKING) { + this.update(0, Trex.status.RUNNING) + this.ducking = false + } + } + + reset() { + this.yPos = this.groundYPos + this.jumpVelocity = 0 + this.jumping = false + this.ducking = false + this.update(0, Trex.status.RUNNING) + this.speedDrop = false + this.jumpCount = 0 + } + + static status = { + CRASHED: "CRASHED", + DUCKING: "DUCKING", + JUMPING: "JUMPING", + RUNNING: "RUNNING", + WAITING: "WAITING" } static config = { DROP_VELOCITY: -5, - GRAVITY: 0.6, + FLASH_OFF: 175, + FLASH_ON: 100, HEIGHT: 47, - INIITAL_JUMP_VELOCITY: -10, + HEIGHT_DUCK: 25, INTRO_DURATION: 1500, - MAX_JUMP_HEIGHT: 30, - MIN_JUMP_HEIGHT: 30, SPEED_DROP_COEFFICIENT: 3, SPRITE_WIDTH: 262, START_X_POS: 50, - WIDTH: 44 + WIDTH: 44, + WIDTH_DUCK: 59 } - static animFrames = { + static normalJumpConfig = { + GRAVITY: 0.6, + MAX_JUMP_HEIGHT: 30, + MIN_JUMP_HEIGHT: 30, + INITIAL_JUMP_VELOCITY: -10 + } + + static BLINK_TIMING = 7000 + + static animFrames: ConfigDict = { WAITING: { frames: [44, 0], msPerFrame: 1000 / 3 @@ -53,16 +277,10 @@ export default class Trex { JUMPING: { frames: [0], msPerFrame: 1000 / 60 + }, + DUCKING: { + frames: [264, 323], + msPerFrame: 1000 / 8 } } - - private static instance: Trex - /** Get singleton instance */ - static getInstance(ctx: CanvasRenderingContext2D, spriteImage: CanvasImageSource) { - if (!this.instance) { - this.instance = new Trex(ctx, spriteImage) - return this.instance - } - return this.instance - } }