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
40 changes: 40 additions & 0 deletions game/src/fx/AnomalyTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Phaser from 'phaser';
import { add404Void, addImageCut, addPixelStretch } from './anomaly';

const BASE = 'assets/stages/rusty-creek/';
const LAYERS = [
{ key: 'rc-test-distance', file: '01-distance-left.png', scroll: 0.2 },
{ key: 'rc-test-traversal', file: '05-traversal-left.png', scroll: 1 },
];

/**
* Dev only (#37 verification): the first Rusty Creek chunk with each anomaly effect on it.
* F9 opens it from the HUD; V shows and hides the effects; arrows pan; Esc closes.
*/
export class AnomalyTest extends Phaser.Scene {
constructor() { super('AnomalyTest'); }

preload(): void {
for (const layer of LAYERS) this.load.image(layer.key, BASE + layer.file);
}

create(): void {
const [distance, traversal] = LAYERS.map((l, i) => this.add.image(0, 0, l.key).setOrigin(0).setScrollFactor(l.scroll, 1).setDepth(i));
const effects: (Phaser.GameObjects.Image | Phaser.GameObjects.Graphics)[] = [
...addImageCut(traversal, { x: 420, y: 330, w: 130, h: 200 }, 44, 3),
addPixelStretch(traversal, { x: 640, y: 180, w: 90, h: 260 }, 0),
addPixelStretch(distance, { x: 300, y: 120, w: 60, h: 160 }, 20),
add404Void(traversal, { x: 900, y: 140, w: 200, h: 170 }),
];
this.cameras.main.setBounds(0, 0, traversal.width, traversal.height);
const keys = this.input.keyboard;
keys?.on('keydown-V', () => effects.forEach((e) => e.setVisible(!e.visible)));
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();
}
}
63 changes: 63 additions & 0 deletions game/src/fx/anomaly.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Phaser from 'phaser';
import { imageCut, pixelStretch, Rect, tornOutline } from './anomalyLayout';

/**
* The anomaly language from docs/06, applied over a stage layer. Each effect is built once from cropped
* copies of the layer's own texture or one static drawing, so nothing redraws per frame (#37).
* Areas are in the layer's texture pixels; the copies inherit its position, scroll factor and depth.
*/

const C = { void: 0x9a9a9a, voidEdge: 0x6e6e6e, icon: 0xf4f4f4, iconLine: 0x5a5a5a, sky: 0x8fb7d9, hill: 0x6aa36f };

function copyOf(layer: Phaser.GameObjects.Image, depthOffset: number): Phaser.GameObjects.Image {
return layer.scene.add.image(layer.x, layer.y, layer.texture.key, layer.frame.name)
.setOrigin(0)
.setScrollFactor(layer.scrollFactorX, layer.scrollFactorY)
.setDepth(layer.depth + depthOffset);
}

/** Image cut: the area shifted sideways by `shift` px and repeated `copies` times. */
export function addImageCut(layer: Phaser.GameObjects.Image, area: Rect, shift: number, copies = 2): Phaser.GameObjects.Image[] {
return imageCut(area, shift, copies).map((s, i) => copyOf(layer, 0.01 * (i + 1))
.setCrop(s.sx, s.sy, s.sw, s.sh)
.setPosition(layer.x + s.dx - s.sx, layer.y + s.dy - s.sy));
}

/** Pixel stretch: one row of the area (rowOffset px from its top) smeared down the whole area. */
export function addPixelStretch(layer: Phaser.GameObjects.Image, area: Rect, rowOffset = 0): Phaser.GameObjects.Image {
const { row, scaleY } = pixelStretch(area, rowOffset);
return copyOf(layer, 0.01)
.setScale(1, scaleY)
.setCrop(row.sx, row.sy, row.sw, row.sh)
.setPosition(layer.x + row.dx - row.sx, layer.y + row.dy - row.sy * scaleY);
}

/** 404 void: flat grey with torn edges and the broken-image icon in the middle, drawn once. */
export function add404Void(layer: Phaser.GameObjects.Image, area: Rect, seed = 404): Phaser.GameObjects.Graphics {
const g = layer.scene.add.graphics({ x: layer.x, y: layer.y })
.setScrollFactor(layer.scrollFactorX, layer.scrollFactorY)
.setDepth(layer.depth + 0.01);
const outline = tornOutline(area, seed).map(([x, y]) => new Phaser.Math.Vector2(x, y));
g.fillStyle(C.void).fillPoints(outline, true);
g.lineStyle(2, C.voidEdge).strokePoints(outline, true);
brokenImageIcon(g, area.x + area.w / 2, area.y + area.h / 2);
return g;
}

/** The old browser's broken-image icon: a page with a folded corner, a tiny landscape, torn across. */
function brokenImageIcon(g: Phaser.GameObjects.Graphics, cx: number, cy: number): void {
const w = 32;
const h = 36;
const x = Math.round(cx - w / 2);
const y = Math.round(cy - h / 2);
const fold = 9;
const page = [[x, y], [x + w - fold, y], [x + w, y + fold], [x + w, y + h], [x, y + h]].map(([px, py]) => new Phaser.Math.Vector2(px, py));
g.fillStyle(C.icon).fillPoints(page, true);
g.fillStyle(C.sky).fillRect(x + 5, y + 12, w - 10, 10);
g.fillStyle(C.hill).fillTriangle(x + 5, y + 22, x + 14, y + 14, x + 21, y + 22);
g.lineStyle(2, C.iconLine).strokePoints(page, true);
g.lineBetween(x + w - fold, y, x + w - fold, y + fold).lineBetween(x + w - fold, y + fold, x + w, y + fold);
// The tear: a jagged line across the page, the lower half nudged right.
g.lineStyle(2, C.void).lineBetween(x - 2, y + 26, x + 8, y + 23).lineBetween(x + 8, y + 23, x + 16, y + 28)
.lineBetween(x + 16, y + 28, x + 24, y + 24).lineBetween(x + 24, y + 24, x + w + 2, y + 27);
}
43 changes: 43 additions & 0 deletions game/src/fx/anomalyLayout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { imageCut, pixelStretch, seeded, tornOutline } from './anomalyLayout';

const area = { x: 100, y: 50, w: 120, h: 80 };

describe('the anomaly language', () => {
it('cuts the same rectangle and repeats it along the shift', () => {
const slices = imageCut(area, 30, 3);
expect(slices).toHaveLength(3);
for (const s of slices) expect([s.sx, s.sy, s.sw, s.sh]).toEqual([100, 50, 120, 80]);
expect(slices.map((s) => s.dx)).toEqual([130, 160, 190]);
expect(slices.every((s) => s.dy === 50)).toBe(true);
});

it('stretches one row down the whole area and clamps the row inside it', () => {
const { row, scaleY } = pixelStretch(area, 5);
expect(row).toEqual({ sx: 100, sy: 55, sw: 120, sh: 1, dx: 100, dy: 50 });
expect(scaleY).toBe(80);
expect(pixelStretch(area, 999).row.sy).toBe(129);
expect(pixelStretch(area, -4).row.sy).toBe(50);
});

it('tears the void edge the same way for the same seed, inside the rectangle', () => {
const a = tornOutline(area, 404);
expect(tornOutline(area, 404)).toEqual(a);
expect(tornOutline(area, 405)).not.toEqual(a);
for (const [x, y] of a) {
expect(x).toBeGreaterThanOrEqual(area.x);
expect(x).toBeLessThanOrEqual(area.x + area.w);
expect(y).toBeGreaterThanOrEqual(area.y);
expect(y).toBeLessThanOrEqual(area.y + area.h);
}
});

it('draws numbers in [0, 1) from the seed', () => {
const rand = seeded(1);
for (let i = 0; i < 100; i++) {
const n = rand();
expect(n).toBeGreaterThanOrEqual(0);
expect(n).toBeLessThan(1);
}
});
});
50 changes: 50 additions & 0 deletions game/src/fx/anomalyLayout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/** Geometry for the anomaly language (docs/06): pure numbers, so the effects are tested without Phaser. */

export interface Rect { x: number; y: number; w: number; h: number }

/** One cropped copy of the art: the source rectangle and where its top-left lands. */
export interface Slice { sx: number; sy: number; sw: number; sh: number; dx: number; dy: number }

/** Image cut: the rectangle shifted sideways and repeated, each copy one more shift along. */
export function imageCut(area: Rect, shift: number, copies = 2): Slice[] {
return Array.from({ length: copies }, (_, i) => ({
sx: area.x, sy: area.y, sw: area.w, sh: area.h,
dx: area.x + shift * (i + 1), dy: area.y,
}));
}

/** Pixel stretch: one row of the art smeared down the whole area, so every pixel becomes a vertical streak. */
export interface Stretch { row: Slice; scaleY: number }

export function pixelStretch(area: Rect, rowOffset = 0): Stretch {
const sy = area.y + Math.max(0, Math.min(area.h - 1, Math.round(rowOffset)));
return { row: { sx: area.x, sy, sw: area.w, sh: 1, dx: area.x, dy: area.y }, scaleY: area.h };
}

/** A tiny seeded generator (mulberry32): the same seed always tears the same edge. */
export function seeded(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}

/**
* 404 void outline: the rectangle with torn edges – every `step` px the edge bites up to `depth` px inwards.
* Points run clockwise from the top-left; all stay inside the rectangle.
*/
export function tornOutline(area: Rect, seed: number, step = 12, depth = 10): [number, number][] {
const rand = seeded(seed);
const bite = () => Math.round(rand() * depth);
const out: [number, number][] = [];
const { x, y, w, h } = area;
for (let i = 0; i < w; i += step) out.push([x + i, y + bite()]);
for (let i = 0; i < h; i += step) out.push([x + w - bite(), y + i]);
for (let i = w; i > 0; i -= step) out.push([x + i, y + h - bite()]);
for (let i = h; i > 0; i -= step) out.push([x + bite(), y + i]);
return out;
}
7 changes: 6 additions & 1 deletion game/src/scenes/registry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type Phaser from 'phaser';
import { AnomalyTest } from '../fx/AnomalyTest';
import { Crossing } from '../story/Crossing';
import { Hud } from '../ui/Hud';
import { Title } from '../ui/Title';
Expand All @@ -7,4 +8,8 @@ import { Title } from '../ui/Title';
* Lane A's story, UI and FX scenes, in draw order (later = on top). main.ts registers this list,
* so a new lane A scene is added here and the shared main.ts stays untouched.
*/
export const LANE_A_SCENES: Phaser.Types.Scenes.SceneType[] = [Hud, Crossing, 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] : []),
];
4 changes: 4 additions & 0 deletions game/src/ui/Hud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export class Hud extends Phaser.Scene {
if (import.meta.env.DEV) this.input.keyboard?.on('keydown-F8', () => {
if (!this.scene.isActive('Crossing')) this.scene.launch('Crossing');
});
// Dev builds: F9 opens the anomaly FX test chunk (#37 verification).
if (import.meta.env.DEV) this.input.keyboard?.on('keydown-F9', () => {
if (!this.scene.isActive('AnomalyTest')) this.scene.launch('AnomalyTest');
});
}

update(): void {
Expand Down
Loading