summaryrefslogtreecommitdiff
path: root/src/game
diff options
context:
space:
mode:
Diffstat (limited to 'src/game')
-rw-r--r--src/game/AudioEngine.ts100
-rw-r--r--src/game/CyberJumpApp.ts888
-rw-r--r--src/game/content.ts123
3 files changed, 1111 insertions, 0 deletions
diff --git a/src/game/AudioEngine.ts b/src/game/AudioEngine.ts
new file mode 100644
index 0000000..1a6f907
--- /dev/null
+++ b/src/game/AudioEngine.ts
@@ -0,0 +1,100 @@
+type AudioContextCtor = typeof AudioContext;
+
+export class AudioEngine {
+ private context?: AudioContext;
+ private master?: GainNode;
+ private drone?: OscillatorNode;
+ private shimmer?: OscillatorNode;
+ private active = false;
+
+ activate(): void {
+ if (this.active) {
+ void this.context?.resume();
+ return;
+ }
+
+ const ctor = window.AudioContext ?? (window as Window & { webkitAudioContext?: AudioContextCtor }).webkitAudioContext;
+ if (!ctor) {
+ return;
+ }
+
+ this.context = new ctor();
+ this.master = this.context.createGain();
+ this.master.gain.value = 0.07;
+ this.master.connect(this.context.destination);
+
+ this.drone = this.context.createOscillator();
+ this.drone.type = 'sawtooth';
+ this.drone.frequency.value = 82;
+ const droneGain = this.context.createGain();
+ droneGain.gain.value = 0.18;
+ this.drone.connect(droneGain);
+ droneGain.connect(this.master);
+ this.drone.start();
+
+ this.shimmer = this.context.createOscillator();
+ this.shimmer.type = 'triangle';
+ this.shimmer.frequency.value = 164;
+ const shimmerGain = this.context.createGain();
+ shimmerGain.gain.value = 0.08;
+ this.shimmer.connect(shimmerGain);
+ shimmerGain.connect(this.master);
+ this.shimmer.start();
+
+ this.active = true;
+ }
+
+ setMood(fidelity: number, cycle: number, layerIndex: number): void {
+ if (!this.context || !this.master || !this.drone || !this.shimmer) {
+ return;
+ }
+
+ const now = this.context.currentTime;
+ const base = 82 + cycle * 8 + layerIndex * 3;
+ this.drone.frequency.linearRampToValueAtTime(base + fidelity * 18, now + 0.18);
+ this.shimmer.frequency.linearRampToValueAtTime(base * 2 + (1 - fidelity) * 70, now + 0.22);
+ this.master.gain.linearRampToValueAtTime(0.045 + fidelity * 0.04, now + 0.25);
+ }
+
+ pulseJump(boost = false): void {
+ if (!this.context || !this.master) {
+ return;
+ }
+
+ const osc = this.context.createOscillator();
+ const gain = this.context.createGain();
+ osc.type = boost ? 'square' : 'sine';
+ osc.frequency.value = boost ? 420 : 260;
+ gain.gain.value = 0.0001;
+ osc.connect(gain);
+ gain.connect(this.master);
+
+ const now = this.context.currentTime;
+ gain.gain.exponentialRampToValueAtTime(0.06, now + 0.01);
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.24);
+ osc.frequency.exponentialRampToValueAtTime(boost ? 170 : 120, now + 0.24);
+ osc.start(now);
+ osc.stop(now + 0.26);
+ }
+
+ pulseSnap(): void {
+ if (!this.context || !this.master) {
+ return;
+ }
+
+ const osc = this.context.createOscillator();
+ const gain = this.context.createGain();
+ osc.type = 'triangle';
+ osc.frequency.value = 680;
+ gain.gain.value = 0.0001;
+ osc.connect(gain);
+ gain.connect(this.master);
+
+ const now = this.context.currentTime;
+ gain.gain.exponentialRampToValueAtTime(0.08, now + 0.01);
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.45);
+ osc.frequency.exponentialRampToValueAtTime(240, now + 0.45);
+ osc.start(now);
+ osc.stop(now + 0.5);
+ }
+}
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();
+}
diff --git a/src/game/content.ts b/src/game/content.ts
new file mode 100644
index 0000000..db94df7
--- /dev/null
+++ b/src/game/content.ts
@@ -0,0 +1,123 @@
+export interface Palette {
+ name: string;
+ descriptor: string;
+ top: string;
+ bottom: string;
+ grid: string;
+ primary: string;
+ accent: string;
+ warm: string;
+ ghost: string;
+ signal: string;
+}
+
+export interface LayerDescriptor {
+ name: string;
+ caption: string;
+ motif: 'core' | 'roots' | 'surface' | 'aqueduct' | 'towers' | 'clouds' | 'orbital';
+}
+
+export const CYCLE_HEIGHT = 900;
+export const WORLD_WIDTH = 18;
+
+export const LAYERS: LayerDescriptor[] = [
+ {
+ name: 'Planetary Core',
+ caption: 'Magnetic furnaces hum below the city. Infrastructure begins as pressure.',
+ motif: 'core'
+ },
+ {
+ name: 'Mycelial Switchyard',
+ caption: 'Roots and fungal circuits share memory through living cable bundles.',
+ motif: 'roots'
+ },
+ {
+ name: 'Surface Arteries',
+ caption: 'Aqueduct rails, markets, and dense habitation recycle heat into movement.',
+ motif: 'surface'
+ },
+ {
+ name: 'Analog Canals',
+ caption: 'Fluid logic, wheels, and pressure gates store power in visible machinery.',
+ motif: 'aqueduct'
+ },
+ {
+ name: 'Tower Choir',
+ caption: 'Needle towers process weather, finance, and rumor in stacked districts.',
+ motif: 'towers'
+ },
+ {
+ name: 'Cloud Mesh',
+ caption: 'Platforms thin into luminous rafts and relay gardens above the weather.',
+ motif: 'clouds'
+ },
+ {
+ name: 'Orbital Threshold',
+ caption: 'Geometry strips itself down to signal, velocity, and intention.',
+ motif: 'orbital'
+ }
+];
+
+export const PALETTES: Palette[] = [
+ {
+ name: 'Neon Furnace',
+ descriptor: 'dense, humid, electric',
+ top: '#140b26',
+ bottom: '#04070f',
+ grid: '#1f4d5f',
+ primary: '#71faff',
+ accent: '#fd4fd0',
+ warm: '#ff9f43',
+ ghost: '#d8f8ff',
+ signal: '#a78bfa'
+ },
+ {
+ name: 'Biolume Relay',
+ descriptor: 'organic, damp, networked',
+ top: '#0f1d17',
+ bottom: '#04070d',
+ grid: '#205a4f',
+ primary: '#70ffbf',
+ accent: '#8de95f',
+ warm: '#f4d35e',
+ ghost: '#d4ffe8',
+ signal: '#64d2ff'
+ },
+ {
+ name: 'Ceramic Dawn',
+ descriptor: 'clean, bright, infrastructural',
+ top: '#1c2448',
+ bottom: '#080b13',
+ grid: '#315f8e',
+ primary: '#98c8ff',
+ accent: '#ff6aa2',
+ warm: '#ffd166',
+ ghost: '#f4faff',
+ signal: '#8cf1ff'
+ },
+ {
+ name: 'Voltage Bloom',
+ descriptor: 'vibrant, ceremonial, airborne',
+ top: '#221140',
+ bottom: '#05070c',
+ grid: '#483f9b',
+ primary: '#b8a7ff',
+ accent: '#ff7b72',
+ warm: '#ffe066',
+ ghost: '#ece9ff',
+ signal: '#6ef3ff'
+ }
+];
+
+export const THOUGHT_LINES = [
+ 'If the world gets simpler as you rise, what exactly are you learning to ignore?',
+ 'The city stores memory in pipes, fungus, glass, and habits. Which medium trusts you most?',
+ 'Ghosts are not dead players. They are adjacent decisions still visible from here.',
+ 'Progress can mean refinement, abstraction, or amputation. Which one are you performing?',
+ 'A platform is a temporary agreement between gravity and intention.',
+ 'Every layer calls itself the real city. Each one merely found a different compression.',
+ 'Information wants a body. Bodies want a future. Cities negotiate between the two.',
+ 'Ascending is easy to narrate and hard to define. Are you escaping or integrating?',
+ 'The brighter the signal, the easier it is to mistake compression for truth.',
+ 'Shared ghosts make failure public and persistence communal.'
+] as const;