diff --git a/game/src/fx/AnomalyTest.ts b/game/src/fx/AnomalyTest.ts new file mode 100644 index 0000000..d8fe645 --- /dev/null +++ b/game/src/fx/AnomalyTest.ts @@ -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(); + } +} diff --git a/game/src/fx/anomaly.ts b/game/src/fx/anomaly.ts new file mode 100644 index 0000000..4105c36 --- /dev/null +++ b/game/src/fx/anomaly.ts @@ -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); +} diff --git a/game/src/fx/anomalyLayout.test.ts b/game/src/fx/anomalyLayout.test.ts new file mode 100644 index 0000000..13e2271 --- /dev/null +++ b/game/src/fx/anomalyLayout.test.ts @@ -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); + } + }); +}); diff --git a/game/src/fx/anomalyLayout.ts b/game/src/fx/anomalyLayout.ts new file mode 100644 index 0000000..4220a74 --- /dev/null +++ b/game/src/fx/anomalyLayout.ts @@ -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; +} diff --git a/game/src/scenes/registry.ts b/game/src/scenes/registry.ts index f72204f..dfd2cba 100644 --- a/game/src/scenes/registry.ts +++ b/game/src/scenes/registry.ts @@ -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'; @@ -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] : []), +]; diff --git a/game/src/ui/Hud.ts b/game/src/ui/Hud.ts index 8eba1c0..0435932 100644 --- a/game/src/ui/Hud.ts +++ b/game/src/ui/Hud.ts @@ -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 {