Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions game/src/fx/AtmosphereTest.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
85 changes: 85 additions & 0 deletions game/src/fx/atmosphere.ts
Original file line number Diff line number Diff line change
@@ -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;
}
32 changes: 32 additions & 0 deletions game/src/fx/atmosphereLayout.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
51 changes: 51 additions & 0 deletions game/src/fx/atmosphereLayout.ts
Original file line number Diff line number Diff line change
@@ -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] };
}
3 changes: 2 additions & 1 deletion game/src/scenes/registry.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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] : []),
];
4 changes: 4 additions & 0 deletions game/src/ui/Hud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading