diff --git a/game/src/fx/AtmosphereTest.ts b/game/src/fx/AtmosphereTest.ts new file mode 100644 index 0000000..13cf4db --- /dev/null +++ b/game/src/fx/AtmosphereTest.ts @@ -0,0 +1,62 @@ +import Phaser from 'phaser'; +import { addLeaves, addReflection, blurForeground } from './atmosphere'; +import { StageState } from './atmosphereLayout'; + +const BASE = 'assets/stages/rusty-creek/'; +/** rc-0-0 in draw order (stage.json), with each layer's scroll factor. */ +const LAYERS = [ + { id: '01-distance', scroll: 0.2 }, + { id: '03-bank', scroll: 1 }, + { id: '02-supports', scroll: 1 }, + { id: '04-water', scroll: 1 }, + { id: '05-traversal', scroll: 1 }, + { id: '06-near-rails', scroll: 1.1 }, + { id: '07-foliage', scroll: 1.15 }, +]; + +/** + * Dev only (#36 verification): the first Rusty Creek chunk with the blur, leaves and reflection. + * F10 opens it from the HUD; V shows and hides the atmosphere; A switches clean / anomaly leaves; arrows pan; Esc closes. + */ +export class AtmosphereTest extends Phaser.Scene { + private state: StageState = 'clean'; + private leaves?: Phaser.GameObjects.Particles.ParticleEmitter; + + constructor() { super('AtmosphereTest'); } + + preload(): void { + for (const layer of LAYERS) this.load.image(`rc-test-${layer.id}`, `${BASE}${layer.id}-left.png`); + } + + create(): void { + const images = LAYERS.map((l, i) => this.add.image(0, 0, `rc-test-${l.id}`).setOrigin(0).setScrollFactor(l.scroll, 1).setDepth(i)); + const byId = (id: string) => images[LAYERS.findIndex((l) => l.id === id)]; + const mirrored = images.filter((_, i) => LAYERS[i].id !== '04-water' && LAYERS[i].id !== '07-foliage'); + const reflection = addReflection(byId('04-water'), mirrored); + const foliage = byId('07-foliage'); + let on = true; + blurForeground(foliage); + this.leaves = addLeaves(this, this.state); + this.cameras.main.setBounds(0, 0, foliage.width, foliage.height); + + const keys = this.input.keyboard; + keys?.on('keydown-V', () => { + on = !on; + reflection.setVisible(on); + this.leaves?.setVisible(on); + if (on) blurForeground(foliage); else foliage.preFX?.clear(); + }); + keys?.on('keydown-A', () => { + this.state = this.state === 'clean' ? 'anomaly' : 'clean'; + this.leaves?.destroy(); + this.leaves = addLeaves(this, this.state).setVisible(on); + }); + keys?.on('keydown-ESC', () => this.scene.stop()); + const arrows = keys?.createCursorKeys(); + this.events.on(Phaser.Scenes.Events.UPDATE, (_t: number, delta: number) => { + if (arrows?.left.isDown) this.cameras.main.scrollX -= delta * 0.6; + if (arrows?.right.isDown) this.cameras.main.scrollX += delta * 0.6; + }); + this.scene.bringToTop(); + } +} diff --git a/game/src/fx/atmosphere.ts b/game/src/fx/atmosphere.ts new file mode 100644 index 0000000..5c4b3cf --- /dev/null +++ b/game/src/fx/atmosphere.ts @@ -0,0 +1,85 @@ +import Phaser from 'phaser'; +import { findWaterline, leafDrift, mirroredTop, rippleOffset, StageState } from './atmosphereLayout'; + +/** + * Atmosphere over a stage chunk (#36, docs/04 layers 4, 7, 8): the foreground blur, falling leaves and the + * water reflection. Everything is built once; per frame only the reflection strips move a pixel or two. + */ + +const LEAF_KEY = 'fx-leaf'; +const LEAF_COLOURS = [0xd9a03a, 0xc9622b, 0xe8c454, 0x9a4a24]; +const STRIP = 4; + +/** Softens the foreground layer (scroll 1.15–1.2) with a WebGL blur; returns false on the Canvas renderer. */ +export function blurForeground(layer: Phaser.GameObjects.Image, strength = 1.2): boolean { + if (!layer.preFX) return false; + layer.preFX.addBlur(1, 2, 2, strength); + return true; +} + +/** Leaves drifting across the view at foreground parallax; they fall when clean and rise in the anomaly state. */ +export function addLeaves(scene: Phaser.Scene, state: StageState, scroll = 1.15): Phaser.GameObjects.Particles.ParticleEmitter { + if (!scene.textures.exists(LEAF_KEY)) { + const g = scene.make.graphics({}, false); + LEAF_COLOURS.forEach((c, i) => g.fillStyle(c).fillRect(i * 6 + 1, 0, 4, 3).fillRect(i * 6, 1, 6, 1)); + g.generateTexture(LEAF_KEY, LEAF_COLOURS.length * 6, 3).destroy(); + LEAF_COLOURS.forEach((_, i) => scene.textures.get(LEAF_KEY).add(i, 0, i * 6, 0, 6, 3)); + } + const { width, height } = scene.scale; + const drift = leafDrift(state); + const emitter = scene.add.particles(0, 0, LEAF_KEY, { + frame: LEAF_COLOURS.map((_, i) => i), + x: { min: -40, max: width + 200 }, + y: state === 'clean' ? -10 : height + 10, + speedX: { min: drift.speedX[0], max: drift.speedX[1] }, + speedY: { min: drift.speedY[0], max: drift.speedY[1] }, + rotate: { min: 0, max: 360 }, + scale: { min: 2, max: 3.5 }, + lifespan: 16_000, + frequency: 180, + advance: 12_000, // start with the view already full of leaves + }).setScrollFactor(0).setDepth(50); + // Screen-space emitter, nudged against the camera so the leaves pass faster than the scenery (parallax > 1). + const cam = scene.cameras.main; + let lastX = cam.scrollX; + const follow = () => { + const dx = (cam.scrollX - lastX) * scroll; + lastX = cam.scrollX; + if (dx) emitter.forEachAlive((p) => { p.x -= dx; }, undefined); + }; + scene.events.on(Phaser.Scenes.Events.UPDATE, follow); + emitter.once(Phaser.GameObjects.Events.DESTROY, () => scene.events.off(Phaser.Scenes.Events.UPDATE, follow)); + return emitter; +} + +/** + * The water mirrors `layers` (docs/04: 1–6) about the waterline, masked to the water layer, with a ripple. + * The mirror is drawn once into a texture; the ripple moves horizontal strips of it. + */ +export function addReflection(water: Phaser.GameObjects.Image, layers: Phaser.GameObjects.Image[], alpha = 0.4): Phaser.GameObjects.Container { + const scene = water.scene; + const key = water.texture.key; + const waterline = findWaterline(water.width, water.height, (x, y) => scene.textures.getPixelAlpha(x, y, key) ?? 0); + const height = water.height - waterline; + const textureKey = `${key}-reflection`; + if (!scene.textures.exists(textureKey)) { + const rt = scene.make.renderTexture({ width: water.width, height }, false); + for (const layer of layers) { + const flipped = scene.make.image({ key: layer.texture.key, add: false }).setOrigin(0).setFlipY(true); + rt.draw(flipped, 0, mirroredTop(waterline, layer.height) - waterline); + flipped.destroy(); + } + rt.saveTexture(textureKey); + } + const strips = Array.from({ length: Math.ceil(height / STRIP) }, (_, i) => + scene.add.image(0, 0, textureKey).setOrigin(0).setCrop(0, i * STRIP, water.width, STRIP)); + const container = scene.add.container(water.x, water.y + waterline, strips) + .setAlpha(alpha) + .setScrollFactor(water.scrollFactorX, water.scrollFactorY) + .setDepth(water.depth + 0.01); + container.setMask(water.createBitmapMask()); + const ripple = (time: number) => strips.forEach((s, i) => { s.x = rippleOffset(i, time); }); + scene.events.on(Phaser.Scenes.Events.UPDATE, ripple); + container.once(Phaser.GameObjects.Events.DESTROY, () => scene.events.off(Phaser.Scenes.Events.UPDATE, ripple)); + return container; +} diff --git a/game/src/fx/atmosphereLayout.test.ts b/game/src/fx/atmosphereLayout.test.ts new file mode 100644 index 0000000..fb8bd4a --- /dev/null +++ b/game/src/fx/atmosphereLayout.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { findWaterline, leafDrift, mirroredTop, rippleOffset } from './atmosphereLayout'; + +describe('the atmosphere layers', () => { + it('finds the first mostly-opaque row of the water layer, ignoring stray pixels above it', () => { + const alphaAt = (x: number, y: number) => (y >= 597 || (y === 100 && x === 0) ? 255 : 0); + expect(findWaterline(2160, 720, alphaAt)).toBe(597); + expect(findWaterline(100, 50, () => 0)).toBe(50); + }); + + it('mirrors a layer about the waterline', () => { + // A 720 px layer flipped and placed here maps row r to 2·596 − 1 − r. + const top = mirroredTop(596, 720); + expect(top).toBe(472); + const row = 100; + expect(top + (720 - 1 - row)).toBe(2 * 596 - 1 - row); + }); + + it('ripples by whole pixels within the amplitude, and neighbouring strips differ', () => { + for (let t = 0; t < 5000; t += 37) { + const o = rippleOffset(3, t, 2); + expect(Number.isInteger(o)).toBe(true); + expect(Math.abs(o)).toBeLessThanOrEqual(2); + } + expect([0, 1, 2, 3].map((i) => rippleOffset(i, 0, 2))).not.toEqual([0, 0, 0, 0]); + }); + + it('lets leaves fall in the clean state and rise in the anomaly state', () => { + expect(Math.min(...leafDrift('clean').speedY)).toBeGreaterThan(0); + expect(Math.max(...leafDrift('anomaly').speedY)).toBeLessThan(0); + }); +}); diff --git a/game/src/fx/atmosphereLayout.ts b/game/src/fx/atmosphereLayout.ts new file mode 100644 index 0000000..68acde5 --- /dev/null +++ b/game/src/fx/atmosphereLayout.ts @@ -0,0 +1,51 @@ +/** Numbers for the atmosphere layers (docs/04: layers 4, 7, 8), kept pure so they are tested without Phaser. */ + +export type StageState = 'clean' | 'anomaly'; + +/** + * The first row where the water layer is mostly opaque: the reflection starts there. + * `alphaAt(x, y)` reads the layer's alpha (0–255); rows and columns are sampled every `step` px. + */ +export function findWaterline(width: number, height: number, alphaAt: (x: number, y: number) => number, step = 4, cover = 0.5): number { + for (let y = 0; y < height; y += step) { + let solid = 0; + let samples = 0; + for (let x = 0; x < width; x += step * 4) { + samples++; + if (alphaAt(x, y) > 128) solid++; + } + if (solid / samples >= cover) { + // Walk back up to the exact first solid row. + while (y > 0 && rowCover(width, y - 1, alphaAt, step * 4) >= cover) y--; + return y; + } + } + return height; +} + +function rowCover(width: number, y: number, alphaAt: (x: number, y: number) => number, stride: number): number { + let solid = 0; + let samples = 0; + for (let x = 0; x < width; x += stride) { + samples++; + if (alphaAt(x, y) > 128) solid++; + } + return solid / samples; +} + +/** Where a vertically flipped copy of a layer `height` px tall goes so it mirrors about `waterline`. */ +export function mirroredTop(waterline: number, height: number): number { + return 2 * waterline - height; +} + +/** Ripple: each horizontal strip of the reflection sways sideways a few whole pixels, out of phase with its neighbours. */ +export function rippleOffset(strip: number, timeMs: number, amplitude = 2): number { + return Math.round(amplitude * Math.sin(timeMs / 420 + strip * 0.7)); +} + +/** Leaves fall and drift left in the clean state; in the anomaly state they drift upward (#36). */ +export function leafDrift(state: StageState): { speedX: [number, number]; speedY: [number, number] } { + return state === 'clean' + ? { speedX: [-60, -20], speedY: [25, 55] } + : { speedX: [-40, 10], speedY: [-55, -25] }; +} diff --git a/game/src/scenes/registry.ts b/game/src/scenes/registry.ts index dfd2cba..fe32f04 100644 --- a/game/src/scenes/registry.ts +++ b/game/src/scenes/registry.ts @@ -1,5 +1,6 @@ import type Phaser from 'phaser'; import { AnomalyTest } from '../fx/AnomalyTest'; +import { AtmosphereTest } from '../fx/AtmosphereTest'; import { Crossing } from '../story/Crossing'; import { Hud } from '../ui/Hud'; import { Title } from '../ui/Title'; @@ -11,5 +12,5 @@ import { Title } from '../ui/Title'; export const LANE_A_SCENES: Phaser.Types.Scenes.SceneType[] = [ Hud, Crossing, Title, // Dev-only test scenes stay out of production builds. - ...(import.meta.env.DEV ? [AnomalyTest] : []), + ...(import.meta.env.DEV ? [AnomalyTest, AtmosphereTest] : []), ]; diff --git a/game/src/ui/Hud.ts b/game/src/ui/Hud.ts index 0435932..aa2c16b 100644 --- a/game/src/ui/Hud.ts +++ b/game/src/ui/Hud.ts @@ -30,6 +30,10 @@ export class Hud extends Phaser.Scene { if (import.meta.env.DEV) this.input.keyboard?.on('keydown-F9', () => { if (!this.scene.isActive('AnomalyTest')) this.scene.launch('AnomalyTest'); }); + // Dev builds: F10 opens the atmosphere test chunk (#36 verification). + if (import.meta.env.DEV) this.input.keyboard?.on('keydown-F10', () => { + if (!this.scene.isActive('AtmosphereTest')) this.scene.launch('AtmosphereTest'); + }); } update(): void {