diff options
| author | Thomas Grothe <grothe.tr@gmail.com> | 2026-04-30 00:36:17 -0400 |
|---|---|---|
| committer | Thomas Grothe <grothe.tr@gmail.com> | 2026-04-30 00:36:17 -0400 |
| commit | dd886585cb9a34af6e6dda24dcfabc8132fdebb1 (patch) | |
| tree | eb7be0922bf57fc3fe4a644f65cf212e0cdaa07b /src/game/CyberJumpApp.ts | |
| parent | 60553f2103ca58405798be7f7d17153f49c2ac7a (diff) | |
Diffstat (limited to 'src/game/CyberJumpApp.ts')
| -rw-r--r-- | src/game/CyberJumpApp.ts | 888 |
1 files changed, 888 insertions, 0 deletions
diff --git a/src/game/CyberJumpApp.ts b/src/game/CyberJumpApp.ts new file mode 100644 index 0000000..db69b08 --- /dev/null +++ b/src/game/CyberJumpApp.ts @@ -0,0 +1,888 @@ +import { AudioEngine } from './AudioEngine'; +import { CYCLE_HEIGHT, LAYERS, PALETTES, THOUGHT_LINES, WORLD_WIDTH, type LayerDescriptor, type Palette } from './content'; +import { NetworkClient, type GhostState, type NetworkStatus } from '../network/NetworkClient'; + +type PlatformKind = 'stable' | 'drift' | 'boost' | 'fragile'; + +interface Platform { + id: number; + kind: PlatformKind; + x: number; + y: number; + width: number; + height: number; + baseX: number; + drift: number; + phase: number; + broken: boolean; +} + +interface Player { + x: number; + y: number; + vx: number; + vy: number; + width: number; + height: number; +} + +interface HudRefs { + height: HTMLSpanElement; + layer: HTMLSpanElement; + fidelity: HTMLSpanElement; + ghosts: HTMLSpanElement; + palette: HTMLSpanElement; + thought: HTMLParagraphElement; + subtle: HTMLParagraphElement; + status: HTMLDivElement; + gameOver: HTMLDivElement; +} + +const GRAVITY = -37; +const MOVE_SPEED = 11; +const AIR_ACCEL = 26; +const BASE_JUMP = 15.8; +const BOOST_JUMP = 22.5; +const PLATFORM_HEIGHT = 0.36; +const MIN_PLATFORM_STEP = 1.7; +const PLATFORM_STEP_PADDING = 0.38; +const HORIZONTAL_REACH_FACTOR = 0.68; +const HORIZONTAL_REACH_BUFFER = 0.72; +const VIEW_BUFFER = 30; +const PLAYER_TRAIL = 8; + +export class CyberJumpApp { + private readonly root: HTMLDivElement; + private readonly canvas: HTMLCanvasElement; + private readonly context: CanvasRenderingContext2D; + private readonly hud: HudRefs; + private readonly audio = new AudioEngine(); + private readonly network: NetworkClient; + private readonly input = { left: false, right: false }; + + private player: Player = { x: 0, y: 0, vx: 0, vy: 0, width: 0.9, height: 1.2 }; + private platforms: Platform[] = []; + private ghosts: GhostState[] = []; + private networkStatus: NetworkStatus = { label: 'network', detail: 'offline', peers: 0 }; + private trail: Array<{ x: number; y: number }> = []; + + private lastTime = 0; + private devicePixelRatio = Math.max(1, window.devicePixelRatio || 1); + private cameraBottom = -4; + private highestY = 0; + private nextPlatformY = 0; + private nextPlatformId = 1; + private cycle = 0; + private thoughtTimer = 0; + private thoughtIndex = 0; + private gameOver = false; + + constructor(root: HTMLDivElement) { + this.root = root; + this.root.innerHTML = this.buildMarkup(); + + this.canvas = this.query<HTMLCanvasElement>('[data-role="canvas"]'); + const context = this.canvas.getContext('2d'); + if (!context) { + throw new Error('Canvas 2D context is unavailable.'); + } + this.context = context; + + this.hud = { + height: this.query<HTMLSpanElement>('[data-role="height"]'), + layer: this.query<HTMLSpanElement>('[data-role="layer"]'), + fidelity: this.query<HTMLSpanElement>('[data-role="fidelity"]'), + ghosts: this.query<HTMLSpanElement>('[data-role="ghosts"]'), + palette: this.query<HTMLSpanElement>('[data-role="palette"]'), + thought: this.query<HTMLParagraphElement>('[data-role="thought"]'), + subtle: this.query<HTMLParagraphElement>('[data-role="subtle"]'), + status: this.query<HTMLDivElement>('[data-role="status"]'), + gameOver: this.query<HTMLDivElement>('[data-role="game-over"]') + }; + + this.network = new NetworkClient( + (players) => { + this.ghosts = players; + }, + (status) => { + this.networkStatus = status; + } + ); + + this.attachEvents(); + this.resize(); + this.reset(); + this.network.start(); + } + + start(): void { + window.requestAnimationFrame(this.loop); + } + + private readonly loop = (timestamp: number): void => { + if (!this.lastTime) { + this.lastTime = timestamp; + } + + const deltaSeconds = Math.min(0.033, (timestamp - this.lastTime) / 1000); + this.lastTime = timestamp; + + this.update(deltaSeconds, timestamp / 1000); + this.render(timestamp / 1000); + + window.requestAnimationFrame(this.loop); + }; + + private attachEvents(): void { + window.addEventListener('resize', this.resize); + window.addEventListener('pointerdown', this.activateAudio, { passive: true }); + window.addEventListener('keydown', this.handleKeyDown); + window.addEventListener('keyup', this.handleKeyUp); + } + + private readonly resize = (): void => { + this.devicePixelRatio = Math.max(1, window.devicePixelRatio || 1); + const width = Math.floor(window.innerWidth * this.devicePixelRatio); + const height = Math.floor(window.innerHeight * this.devicePixelRatio); + this.canvas.width = width; + this.canvas.height = height; + }; + + private readonly activateAudio = (): void => { + this.audio.activate(); + }; + + private readonly handleKeyDown = (event: KeyboardEvent): void => { + if (event.code === 'ArrowLeft' || event.code === 'KeyA') { + this.input.left = true; + this.activateAudio(); + } + + if (event.code === 'ArrowRight' || event.code === 'KeyD') { + this.input.right = true; + this.activateAudio(); + } + + if ((event.code === 'Space' || event.code === 'ArrowUp' || event.code === 'KeyW') && this.gameOver) { + this.reset(); + this.activateAudio(); + } + }; + + private readonly handleKeyUp = (event: KeyboardEvent): void => { + if (event.code === 'ArrowLeft' || event.code === 'KeyA') { + this.input.left = false; + } + + if (event.code === 'ArrowRight' || event.code === 'KeyD') { + this.input.right = false; + } + }; + + private reset(): void { + this.player = { x: 0, y: 1.2, vx: 0, vy: 0, width: 0.9, height: 1.2 }; + this.platforms = []; + this.ghosts = []; + this.trail = []; + this.highestY = 1.2; + this.cameraBottom = -4; + this.nextPlatformY = 0; + this.nextPlatformId = 1; + this.cycle = 0; + this.thoughtTimer = 1; + this.thoughtIndex = 0; + this.gameOver = false; + + this.platforms.push({ + id: this.nextPlatformId++, + kind: 'stable', + x: 0, + y: -0.1, + width: 4.4, + height: PLATFORM_HEIGHT, + baseX: 0, + drift: 0, + phase: 0, + broken: false + }); + + this.nextPlatformY = 2.8; + this.generatePlatforms(this.cameraBottom + VIEW_BUFFER); + this.setThought('Signal reacquired. Ascend until the world sheds another layer.'); + this.hud.gameOver.classList.remove('is-visible'); + } + + private update(deltaSeconds: number, elapsedSeconds: number): void { + const height = Math.max(0, this.highestY); + const layer = this.getLayer(height); + const fidelity = this.getFidelity(height); + const cycle = Math.floor(height / CYCLE_HEIGHT); + + this.audio.setMood(fidelity, cycle, layer.index); + this.updateThoughts(deltaSeconds, cycle, layer); + + if (this.gameOver) { + this.syncHud(height, layer, fidelity); + return; + } + + const moveAxis = (this.input.right ? 1 : 0) - (this.input.left ? 1 : 0); + const targetVelocity = moveAxis * MOVE_SPEED; + this.player.vx = lerp(this.player.vx, targetVelocity, Math.min(1, AIR_ACCEL * deltaSeconds)); + this.player.vy += GRAVITY * deltaSeconds; + + const previousY = this.player.y; + this.player.x += this.player.vx * deltaSeconds; + this.player.y += this.player.vy * deltaSeconds; + + const wrapLimit = WORLD_WIDTH * 0.5 + 1.2; + if (this.player.x < -wrapLimit) { + this.player.x = wrapLimit; + } else if (this.player.x > wrapLimit) { + this.player.x = -wrapLimit; + } + + this.updatePlatforms(elapsedSeconds); + this.resolveCollisions(previousY); + + this.highestY = Math.max(this.highestY, this.player.y); + this.cameraBottom = Math.max(this.cameraBottom, this.player.y - 6.2); + this.generatePlatforms(this.cameraBottom + VIEW_BUFFER); + this.platforms = this.platforms.filter((platform) => platform.y > this.cameraBottom - 8 || !platform.broken); + + if (cycle > this.cycle) { + this.cycle = cycle; + this.audio.pulseSnap(); + this.setThought(`Cycle ${cycle + 1} initialized. ${layer.descriptor.caption}`); + } + + if (this.player.y < this.cameraBottom - 5.5) { + this.gameOver = true; + this.hud.gameOver.classList.add('is-visible'); + this.setThought('You dropped out of the visible stack. Space rebinds your trajectory.'); + } + + this.pushTrail(); + this.network.updateState({ + x: this.player.x, + y: this.player.y, + vx: this.player.vx, + vy: this.player.vy, + height: this.highestY, + layer: layer.descriptor.name, + cycle + }); + + this.syncHud(height, layer, fidelity); + } + + private updatePlatforms(elapsedSeconds: number): void { + for (const platform of this.platforms) { + if (platform.kind !== 'drift' || platform.broken) { + continue; + } + + platform.x = platform.baseX + Math.sin(elapsedSeconds * 1.15 + platform.phase) * platform.drift; + } + } + + private resolveCollisions(previousY: number): void { + if (this.player.vy >= 0) { + return; + } + + const previousBottom = previousY - this.player.height * 0.5; + const currentBottom = this.player.y - this.player.height * 0.5; + + for (const platform of this.platforms) { + if (platform.broken) { + continue; + } + + const platformTop = platform.y + platform.height * 0.5; + const overlapX = Math.abs(this.player.x - platform.x) <= platform.width * 0.5 + this.player.width * 0.38; + const crossedTop = previousBottom >= platformTop && currentBottom <= platformTop; + + if (!overlapX || !crossedTop) { + continue; + } + + this.player.y = platformTop + this.player.height * 0.5; + this.player.vy = platform.kind === 'boost' ? BOOST_JUMP : BASE_JUMP; + if (platform.kind === 'fragile') { + platform.broken = true; + } + this.audio.pulseJump(platform.kind === 'boost'); + return; + } + } + + private generatePlatforms(targetY: number): void { + while (this.nextPlatformY < targetY) { + const previousPlatform = this.platforms[this.platforms.length - 1]; + const difficulty = clamp(this.nextPlatformY / 3600, 0, 1); + const step = this.choosePlatformStep(previousPlatform, difficulty); + const kind = this.choosePlatformKind(difficulty); + const width = this.getPlatformWidth(kind, difficulty); + const y = previousPlatform.y + step; + const baseX = this.choosePlatformX(previousPlatform, width, step, difficulty); + + this.platforms.push({ + id: this.nextPlatformId++, + kind, + x: baseX, + y, + width, + height: PLATFORM_HEIGHT, + baseX, + drift: kind === 'drift' ? 0.9 + difficulty * 1.2 + Math.random() * 0.9 : 0, + phase: Math.random() * Math.PI * 2, + broken: false + }); + + this.nextPlatformY = y; + } + } + + private choosePlatformKind(difficulty: number): PlatformKind { + const roll = Math.random(); + const stableWeight = 0.73 - difficulty * 0.25; + const driftWeight = 0.15 + difficulty * 0.12; + const fragileWeight = 0.07 + difficulty * 0.1; + + if (roll < stableWeight) { + return 'stable'; + } + + if (roll < stableWeight + driftWeight) { + return 'drift'; + } + + if (roll < stableWeight + driftWeight + fragileWeight) { + return 'fragile'; + } + + return 'boost'; + } + + private getPlatformWidth(kind: PlatformKind, difficulty: number): number { + switch (kind) { + case 'boost': + return clamp(1.95 - difficulty * 0.3, 1.5, 1.95); + case 'drift': + return clamp(2.3 - difficulty * 0.75, 1.35, 2.3); + case 'fragile': + return clamp(2.05 - difficulty * 0.8, 1.2, 2.05); + case 'stable': + default: + return clamp(2.6 - difficulty * 1.0, 1.35, 2.6); + } + } + + private choosePlatformStep(previousPlatform: Platform, difficulty: number): number { + const maxStep = this.getReachableStepCap(previousPlatform); + const minStep = clamp(MIN_PLATFORM_STEP + difficulty * 0.35, 1.45, maxStep - 0.32); + const pressure = clamp(0.34 + difficulty * 0.34 + Math.random() * 0.24, 0.2, 0.96); + + return lerp(minStep, maxStep, pressure); + } + + private choosePlatformX(previousPlatform: Platform, width: number, step: number, difficulty: number): number { + const span = WORLD_WIDTH * 0.5 - width * 0.6; + const offsetCap = Math.min(this.getReachableOffsetCap(previousPlatform, step, width), span * 2); + const lowChallenge = Math.min(offsetCap * (0.12 + difficulty * 0.3), Math.max(0.24, offsetCap - 0.18)); + + let targetX = previousPlatform.x; + if (Math.random() < 0.18) { + const relaxedOffset = (Math.random() * 2 - 1) * Math.min(0.9, offsetCap); + targetX += relaxedOffset; + } else { + const direction = Math.random() < 0.5 ? -1 : 1; + const offset = lerp(lowChallenge, offsetCap, 0.3 + difficulty * 0.28 + Math.random() * 0.42); + targetX += direction * offset; + } + + if (targetX < -span || targetX > span) { + targetX = previousPlatform.x - (targetX - previousPlatform.x) * 0.72; + } + + return clamp(targetX, -span, span); + } + + private getReachableStepCap(platform: Platform): number { + const jumpVelocity = platform.kind === 'boost' ? BOOST_JUMP : BASE_JUMP; + const apexRise = (jumpVelocity * jumpVelocity) / (2 * -GRAVITY); + const safeStep = apexRise - PLATFORM_HEIGHT - PLATFORM_STEP_PADDING; + + return clamp(safeStep, 1.9, platform.kind === 'boost' ? 5.6 : 2.6); + } + + private getReachableOffsetCap(platform: Platform, step: number, nextWidth: number): number { + const jumpVelocity = platform.kind === 'boost' ? BOOST_JUMP : BASE_JUMP; + const gravity = -GRAVITY; + const discriminant = Math.max(0, jumpVelocity * jumpVelocity - 2 * gravity * step); + const descentTime = (jumpVelocity + Math.sqrt(discriminant)) / gravity; + const travelDistance = MOVE_SPEED * Math.max(0.24, descentTime - 0.08) * HORIZONTAL_REACH_FACTOR; + const landingSlack = nextWidth * 0.5 + this.player.width * 0.34; + const safeOffset = travelDistance + landingSlack - HORIZONTAL_REACH_BUFFER; + + return clamp(safeOffset, 1.7, platform.kind === 'boost' ? 6.2 : 3.6); + } + + private updateThoughts(deltaSeconds: number, cycle: number, layer: LayerState): void { + this.thoughtTimer -= deltaSeconds; + if (this.thoughtTimer > 0) { + return; + } + + this.thoughtIndex = (this.thoughtIndex + 1 + cycle) % THOUGHT_LINES.length; + this.setThought(THOUGHT_LINES[this.thoughtIndex]); + this.hud.subtle.textContent = layer.descriptor.caption; + this.thoughtTimer = 8 + Math.random() * 4; + } + + private syncHud(height: number, layer: LayerState, fidelity: number): void { + const palette = this.getPalette(Math.floor(height / CYCLE_HEIGHT)); + this.hud.height.textContent = `${Math.floor(height)} m`; + this.hud.layer.textContent = layer.descriptor.name; + this.hud.fidelity.textContent = `${Math.round(fidelity * 100)}% ยท ${palette.descriptor}`; + this.hud.ghosts.textContent = `${this.networkStatus.peers}`; + this.hud.palette.textContent = palette.name; + this.hud.status.textContent = `${this.networkStatus.label}: ${this.networkStatus.detail}`; + } + + private render(elapsedSeconds: number): void { + const ctx = this.context; + const { width, height } = this.canvas; + const palette = this.getPalette(this.cycle); + const layer = this.getLayer(Math.max(0, this.highestY)); + const fidelity = this.getFidelity(Math.max(0, this.highestY)); + const visibleHeight = 22; + const scale = Math.min(width / (WORLD_WIDTH * 1.15), height / visibleHeight); + const viewTop = this.cameraBottom + height / scale; + + ctx.clearRect(0, 0, width, height); + + const background = ctx.createLinearGradient(0, 0, 0, height); + background.addColorStop(0, palette.top); + background.addColorStop(1, palette.bottom); + ctx.fillStyle = background; + ctx.fillRect(0, 0, width, height); + + this.drawAtmosphere(ctx, width, height, palette, fidelity, elapsedSeconds); + this.drawMotif(ctx, width, height, scale, layer.descriptor, palette, fidelity, elapsedSeconds); + this.drawGrid(ctx, width, height, scale, palette, fidelity); + + for (const platform of this.platforms) { + if (platform.y < this.cameraBottom - 1 || platform.y > viewTop + 2) { + continue; + } + this.drawPlatform(ctx, platform, scale, palette, fidelity); + } + + for (const ghost of this.ghosts) { + if (ghost.y < this.cameraBottom - 2 || ghost.y > viewTop + 2) { + continue; + } + this.drawGhost(ctx, ghost, scale, palette); + } + + this.drawTrail(ctx, scale, palette); + this.drawPlayer(ctx, scale, palette, fidelity); + this.drawCompass(ctx, width, height, palette, fidelity); + } + + private drawAtmosphere( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + palette: Palette, + fidelity: number, + elapsedSeconds: number + ): void { + const sparkleCount = Math.floor(14 + fidelity * 34 + this.cycle * 4); + ctx.save(); + for (let index = 0; index < sparkleCount; index += 1) { + const seed = hash(index + this.cycle * 131); + const x = seed * width; + const y = (hash(index * 19 + this.cycle * 17) * height + elapsedSeconds * (8 + hash(index * 23) * 16)) % height; + const radius = (0.6 + hash(index * 29) * 2.3) * this.devicePixelRatio; + ctx.globalAlpha = 0.18 + hash(index * 31) * 0.34; + ctx.fillStyle = index % 5 === 0 ? palette.signal : palette.primary; + ctx.beginPath(); + ctx.arc(x, height - y, radius, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + + private drawGrid( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + scale: number, + palette: Palette, + fidelity: number + ): void { + ctx.save(); + ctx.strokeStyle = palette.grid; + ctx.globalAlpha = 0.12 + fidelity * 0.1; + ctx.lineWidth = this.devicePixelRatio; + + const verticalLines = Math.max(4, Math.floor(6 + fidelity * 7)); + for (let index = 0; index <= verticalLines; index += 1) { + const x = (index / verticalLines) * width; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, height); + ctx.stroke(); + } + + const horizontalStep = 2 * scale; + const offset = (this.cameraBottom * scale) % horizontalStep; + for (let y = -offset; y < height + horizontalStep; y += horizontalStep) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(width, y); + ctx.stroke(); + } + + ctx.restore(); + } + + private drawMotif( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + scale: number, + layer: LayerDescriptor, + palette: Palette, + fidelity: number, + elapsedSeconds: number + ): void { + ctx.save(); + ctx.globalAlpha = 0.16 + fidelity * 0.22; + ctx.strokeStyle = palette.accent; + ctx.fillStyle = palette.signal; + ctx.lineWidth = Math.max(1, this.devicePixelRatio * 1.4); + + switch (layer.motif) { + case 'core': { + for (let index = 0; index < 7; index += 1) { + const radius = (4 + index * 1.3) * scale; + ctx.beginPath(); + ctx.arc(width * 0.5, height + scale * 4, radius, Math.PI, Math.PI * 2); + ctx.stroke(); + } + break; + } + case 'roots': { + const strands = Math.floor(7 + fidelity * 12); + for (let index = 0; index < strands; index += 1) { + const x = (index / Math.max(1, strands - 1)) * width; + ctx.beginPath(); + ctx.moveTo(x, height); + ctx.bezierCurveTo( + x + Math.sin(index + elapsedSeconds) * 60, + height * 0.75, + x + Math.cos(index * 1.3 + elapsedSeconds * 0.7) * 90, + height * 0.35, + x + Math.sin(index * 2.2 + elapsedSeconds * 0.5) * 40, + 0 + ); + ctx.stroke(); + } + break; + } + case 'surface': { + const blocks = Math.floor(9 + fidelity * 12); + for (let index = 0; index < blocks; index += 1) { + const seed = hash(index * 13 + this.cycle * 17); + const x = seed * width; + const blockWidth = (18 + seed * 70) * this.devicePixelRatio; + const blockHeight = (80 + hash(index * 23) * 180) * this.devicePixelRatio; + ctx.fillRect(x, height - blockHeight, blockWidth, blockHeight); + } + break; + } + case 'aqueduct': { + const lanes = 6; + for (let index = 0; index < lanes; index += 1) { + const y = height * (0.2 + index * 0.13) + Math.sin(elapsedSeconds + index) * 10; + ctx.lineWidth = 6 * this.devicePixelRatio; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(width, y); + ctx.stroke(); + ctx.lineWidth = Math.max(1, this.devicePixelRatio * 1.2); + } + break; + } + case 'towers': { + const towers = Math.floor(12 + fidelity * 16); + for (let index = 0; index < towers; index += 1) { + const seed = hash(index * 7 + this.cycle * 41); + const x = seed * width; + const towerWidth = (8 + hash(index * 11) * 24) * this.devicePixelRatio; + const towerHeight = (120 + hash(index * 17) * 260) * this.devicePixelRatio; + ctx.fillRect(x, height - towerHeight, towerWidth, towerHeight); + } + break; + } + case 'clouds': { + const clouds = Math.floor(5 + fidelity * 8); + for (let index = 0; index < clouds; index += 1) { + const seed = hash(index * 9 + this.cycle * 53); + const x = (seed * width + elapsedSeconds * 20 * (index % 2 === 0 ? 1 : -1)) % width; + const y = height * (0.18 + hash(index * 27) * 0.58); + ctx.beginPath(); + ctx.ellipse(x, y, 36 * scale * 0.1, 16 * scale * 0.1, 0, 0, Math.PI * 2); + ctx.fill(); + } + break; + } + case 'orbital': { + const bands = Math.floor(6 + fidelity * 10); + for (let index = 0; index < bands; index += 1) { + const y = height * 0.12 + index * 42 * this.devicePixelRatio; + ctx.beginPath(); + ctx.moveTo(width * 0.1, y + Math.sin(elapsedSeconds + index) * 14); + ctx.lineTo(width * 0.9, y + Math.cos(elapsedSeconds * 0.8 + index) * 14); + ctx.stroke(); + } + break; + } + } + + ctx.restore(); + } + + private drawPlatform(ctx: CanvasRenderingContext2D, platform: Platform, scale: number, palette: Palette, fidelity: number): void { + const center = this.worldToScreen(platform.x, platform.y, scale); + const width = platform.width * scale; + const height = platform.height * scale; + const radius = Math.max(4, height * 0.6); + + ctx.save(); + ctx.globalAlpha = platform.broken ? 0.2 : 0.95; + ctx.fillStyle = platform.kind === 'boost' ? palette.warm : platform.kind === 'fragile' ? palette.signal : palette.primary; + ctx.shadowBlur = (platform.kind === 'boost' ? 22 : 12) * fidelity * this.devicePixelRatio; + ctx.shadowColor = ctx.fillStyle; + roundRect(ctx, center.x - width * 0.5, center.y - height * 0.5, width, height, radius); + ctx.fill(); + + if (platform.kind === 'drift') { + ctx.strokeStyle = palette.accent; + ctx.lineWidth = Math.max(1, this.devicePixelRatio * 1.2); + ctx.stroke(); + } + ctx.restore(); + } + + private drawGhost(ctx: CanvasRenderingContext2D, ghost: GhostState, scale: number, palette: Palette): void { + const ageSeconds = Math.max(0, Date.now() - ghost.updatedAt) / 1000; + const alpha = clamp(0.48 - ageSeconds * 0.14, 0.1, 0.48); + const center = this.worldToScreen(ghost.x, ghost.y, scale); + const width = 0.74 * scale; + const height = 1.02 * scale; + + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = palette.ghost; + ctx.strokeStyle = palette.signal; + ctx.lineWidth = Math.max(1, this.devicePixelRatio); + roundRect(ctx, center.x - width * 0.5, center.y - height * 0.5, width, height, width * 0.16); + ctx.fill(); + ctx.stroke(); + ctx.restore(); + } + + private drawTrail(ctx: CanvasRenderingContext2D, scale: number, palette: Palette): void { + ctx.save(); + for (let index = 0; index < this.trail.length; index += 1) { + const point = this.trail[index]; + const center = this.worldToScreen(point.x, point.y, scale); + const alpha = (index + 1) / this.trail.length; + ctx.globalAlpha = alpha * 0.14; + ctx.fillStyle = palette.signal; + ctx.beginPath(); + ctx.arc(center.x, center.y, Math.max(2, scale * 0.08 * alpha), 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + + private drawPlayer(ctx: CanvasRenderingContext2D, scale: number, palette: Palette, fidelity: number): void { + const center = this.worldToScreen(this.player.x, this.player.y, scale); + const width = this.player.width * scale; + const height = this.player.height * scale; + const simplify = fidelity < 0.22; + + ctx.save(); + ctx.shadowBlur = 24 * this.devicePixelRatio; + ctx.shadowColor = palette.accent; + ctx.fillStyle = simplify ? palette.primary : palette.accent; + roundRect(ctx, center.x - width * 0.5, center.y - height * 0.5, width, height, simplify ? 2 : width * 0.24); + ctx.fill(); + + if (!simplify) { + ctx.fillStyle = palette.primary; + ctx.fillRect(center.x - width * 0.18, center.y - height * 0.1, width * 0.36, height * 0.16); + ctx.fillStyle = palette.warm; + ctx.fillRect(center.x - width * 0.08, center.y + height * 0.1, width * 0.16, height * 0.24); + } + ctx.restore(); + } + + private drawCompass( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + palette: Palette, + fidelity: number + ): void { + const barWidth = Math.min(width * 0.38, 320 * this.devicePixelRatio); + const x = width - barWidth - 24 * this.devicePixelRatio; + const y = height - 28 * this.devicePixelRatio; + + ctx.save(); + ctx.globalAlpha = 0.85; + ctx.fillStyle = 'rgba(15, 23, 42, 0.55)'; + roundRect(ctx, x, y, barWidth, 10 * this.devicePixelRatio, 999); + ctx.fill(); + + ctx.fillStyle = palette.primary; + roundRect(ctx, x, y, barWidth * fidelity, 10 * this.devicePixelRatio, 999); + ctx.fill(); + ctx.restore(); + } + + private pushTrail(): void { + this.trail.push({ x: this.player.x, y: this.player.y }); + if (this.trail.length > PLAYER_TRAIL) { + this.trail.shift(); + } + } + + private setThought(text: string): void { + this.hud.thought.textContent = text; + this.thoughtTimer = 9 + Math.random() * 3; + } + + private getLayer(height: number): LayerState { + const index = Math.min(LAYERS.length - 1, Math.floor(height / 700)); + return { index, descriptor: LAYERS[index] }; + } + + private getPalette(cycle: number): Palette { + return PALETTES[cycle % PALETTES.length]; + } + + private getFidelity(height: number): number { + const progress = (height % CYCLE_HEIGHT) / CYCLE_HEIGHT; + return clamp(1 - progress, 0.1, 1); + } + + private worldToScreen(x: number, y: number, scale: number): { x: number; y: number } { + const screenX = this.canvas.width * 0.5 + x * scale; + const screenY = this.canvas.height - (y - this.cameraBottom) * scale; + return { x: screenX, y: screenY }; + } + + private buildMarkup(): string { + return ` + <div class="cyberjump-shell"> + <canvas class="cyberjump-canvas" data-role="canvas"></canvas> + <div class="cyberjump-overlay"> + <section class="cyberjump-panel cyberjump-stats"> + <p class="cyberjump-label">Run Metrics</p> + <div class="cyberjump-grid"> + <div class="cyberjump-stat"> + <span class="cyberjump-stat-name">Height</span> + <span class="cyberjump-stat-value" data-role="height">0 m</span> + </div> + <div class="cyberjump-stat"> + <span class="cyberjump-stat-name">Realm</span> + <span class="cyberjump-stat-value" data-role="layer">Planetary Core</span> + </div> + <div class="cyberjump-stat"> + <span class="cyberjump-stat-name">Fidelity</span> + <span class="cyberjump-stat-value" data-role="fidelity">100%</span> + </div> + <div class="cyberjump-stat"> + <span class="cyberjump-stat-name">Ghosts</span> + <span class="cyberjump-stat-value" data-role="ghosts">0</span> + </div> + <div class="cyberjump-stat"> + <span class="cyberjump-stat-name">Palette</span> + <span class="cyberjump-stat-value" data-role="palette">Neon Furnace</span> + </div> + </div> + </section> + + <section class="cyberjump-panel cyberjump-thoughts"> + <p class="cyberjump-label">Transmission</p> + <p class="cyberjump-thought" data-role="thought"></p> + <p class="cyberjump-subtle" data-role="subtle"></p> + </section> + + <section class="cyberjump-panel cyberjump-status" data-role="status"></section> + + <section class="cyberjump-panel cyberjump-gameover" data-role="game-over"> + <p class="cyberjump-label">Run Interrupted</p> + <h1 class="cyberjump-title">Signal lost</h1> + <p class="cyberjump-copy">Press <span class="cyberjump-accent">Space</span> to restart the ascent.</p> + </section> + </div> + </div> + `; + } + + private query<T extends Element>(selector: string): T { + const element = this.root.querySelector<T>(selector); + if (!element) { + throw new Error(`Missing required element: ${selector}`); + } + return element; + } +} + +interface LayerState { + index: number; + descriptor: LayerDescriptor; +} + +function lerp(start: number, end: number, amount: number): number { + return start + (end - start) * amount; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function hash(seed: number): number { + const value = Math.sin(seed * 127.1 + 311.7) * 43758.5453123; + return value - Math.floor(value); +} + +function roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number +): void { + const bounded = Math.min(radius, width * 0.5, height * 0.5); + ctx.beginPath(); + ctx.moveTo(x + bounded, y); + ctx.lineTo(x + width - bounded, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + bounded); + ctx.lineTo(x + width, y + height - bounded); + ctx.quadraticCurveTo(x + width, y + height, x + width - bounded, y + height); + ctx.lineTo(x + bounded, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - bounded); + ctx.lineTo(x, y + bounded); + ctx.quadraticCurveTo(x, y, x + bounded, y); + ctx.closePath(); +} |
