diff --git a/art/sprites/previews/poncho-options.png b/art/sprites/previews/poncho-options.png new file mode 100644 index 0000000..916e04d Binary files /dev/null and b/art/sprites/previews/poncho-options.png differ diff --git a/docs/02-GAME-DESIGN.md b/docs/02-GAME-DESIGN.md index ad5d811..f7b8bb2 100644 --- a/docs/02-GAME-DESIGN.md +++ b/docs/02-GAME-DESIGN.md @@ -46,7 +46,7 @@ The weapon is switched in the pocket computer menu (or ←/→ while the menu is A ☰ button in the top-right corner, Esc or the pad's Start open the **game menu** and pause the stage (`game/src/ui/menu.ts`, plain DOM over the canvas so it stays crisp and works with mouse, touch and keyboard). Four tabs: - **Controls** – the table above, for keyboard and gamepad. -- **Settings** – sound on/off and volume, zoom (the game's own view or 133 / 200 / 286 % wider), full screen, and **Keys**. In Keys, click an action and press its new key (Esc keeps the old one). The key then belongs to that action alone and is taken from any other. The choice is saved in the browser, and "Reset to the defaults" brings back both laptop layouts. Esc, Tab and the debug keys stay the game's (`player/physicalKeys.ts` `Bindings`). +- **Settings** – sound on/off and volume, zoom (the game's own view or 133 / 200 / 286 % wider), full screen, **Hero** (a brighter, cooler outline on the hero, easier to see against the leaves; playtest R1), and **Keys**. In Keys, click an action and press its new key (Esc keeps the old one). The key then belongs to that action alone and is taken from any other. The choice is saved in the browser, and "Reset to the defaults" brings back both laptop layouts. Esc, Tab and the debug keys stay the game's (`player/physicalKeys.ts` `Bindings`). - **Debug** – debug mode and its switches (`game/src/core/debug.ts`); the corner line names every switch that is on, so a playtest screenshot records what was showing: - **Layers**: every stage layer by id and z, switched off in all chunks at once; with the **purple backdrop** any hole or seam in a plate behind it shows at once (the hot-dog rule, docs/09). - **Collision**: walk lines, one-way beams, ladders, walls, water, hazards, triggers (the F2 legend). diff --git a/docs/meetings/playtest-24-oct.md b/docs/meetings/playtest-24-oct.md index fd3a507..5bcc25d 100644 --- a/docs/meetings/playtest-24-oct.md +++ b/docs/meetings/playtest-24-oct.md @@ -11,7 +11,7 @@ Vatsal and Lex played the live slice on 24 Oct and recorded what to fix ([A15](t | # | What they said | Interpretation | Status | | --- | --- | --- | --- | -| R1 | The hero blends into the background (trees, tree line, buildings); try a white poncho or a purple "Teletubby" sweater – "something for Alex to decide" | The grey/blue/teal THD poncho sits in the same values as the autumn scenery | ❓ colour; a brighter rim or outline is the quick fix | +| R1 | The hero blends into the background (trees, tree line, buildings); try a white poncho or a purple "Teletubby" sweater – "something for Alex to decide" | The grey/blue/teal THD poncho sits in the same values as the autumn scenery | 🔧 the quick fix is in: Settings → Hero → "Brighter outline" (a cool white rim instead of the warm one); the poncho colour itself is still ❓ Alexander's | | R2 | Enemies are "way too small", barely visible; the professor didn't realise the robots were enemies. Vatsal: robots +300 % (≈ half the hero), hero ×1.5. Lex: "maybe don't make them bigger, but make them actually visible" | Rovers are 25 px against an 80 px hero. Scale against the world is the open question (Alexander: "is the character too small?") | ❓ bigger vs more visible – see the scale note below | | R3 | The attack animation is confusing, "barely noticeable that it is an attack" | Swing and hit need a visible arc, flash and impact | 🔧 fights branch | | R4 | The foreground bar or pipe in the middle of the view clutters it: move it behind (in front of the tree line, behind the buildings) | Rusty Creek's near-rails plate (z 6, scroll 1.1) crosses the play area | ⏭ a layer move; check with the Debug tab's layer switches | diff --git a/game/src/core/preferences.test.ts b/game/src/core/preferences.test.ts new file mode 100644 index 0000000..f933c03 --- /dev/null +++ b/game/src/core/preferences.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { HERO_OUTLINE, heroRim, PREFERENCES_KEY, PreferenceStore } from './preferences'; + +function memory(initial: Record = {}) { + const data = { ...initial }; + return { data, getItem: (k: string) => data[k] ?? null, setItem: (k: string, v: string) => { data[k] = v; } }; +} + +describe('preferences', () => { + it('start with the art\'s own warm rim and save the brighter outline', () => { + const store = memory(); + const prefs = new PreferenceStore(store); + expect(prefs.state.heroOutline).toBe(false); + const seen: boolean[] = []; + prefs.on(p => seen.push(p.heroOutline)); + prefs.set({ heroOutline: true }); + expect(seen).toEqual([true]); + expect(new PreferenceStore(store).state.heroOutline).toBe(true); + expect(JSON.parse(store.data[PREFERENCES_KEY])).toEqual({ heroOutline: true }); + }); + it('ignore broken data', () => { + expect(new PreferenceStore(memory({ [PREFERENCES_KEY]: '{oops' })).state.heroOutline).toBe(false); + }); + it('pick the hero rim', () => { + const actor = { colour: 0xffc878, strength: 0.45 }; + expect(heroRim({ heroOutline: false }, actor)).toBe(actor); + expect(heroRim({ heroOutline: true }, actor)).toBe(HERO_OUTLINE); + }); +}); diff --git a/game/src/core/preferences.ts b/game/src/core/preferences.ts new file mode 100644 index 0000000..ca59639 --- /dev/null +++ b/game/src/core/preferences.ts @@ -0,0 +1,44 @@ +/** + * The player's own settings that outlast a page load (Settings tab). Pure – no Phaser here; saved in the browser, + * and a broken or blocked storage just means the defaults. + */ +export interface Preferences { + /** Playtest R1: the hero blends into the autumn scenery – a brighter, cooler outline lifts him off it. */ + heroOutline: boolean; +} + +export const PREFERENCES_KEY = 'deggster.preferences'; +const DEFAULTS: Preferences = { heroOutline: false }; + +interface Store { getItem(key: string): string | null; setItem(key: string, value: string): void } +type Listener = (value: Preferences) => void; + +export class PreferenceStore { + private value: Preferences = { ...DEFAULTS }; + private listeners = new Set(); + constructor(private store?: Store) { + try { + const saved = JSON.parse(store?.getItem(PREFERENCES_KEY) ?? '{}') as Partial>; + if (typeof saved.heroOutline === 'boolean') this.value.heroOutline = saved.heroOutline; + } catch { /* the defaults */ } + } + get state(): Preferences { return this.value; } + set(patch: Partial): void { + this.value = { ...this.value, ...patch }; + try { this.store?.setItem(PREFERENCES_KEY, JSON.stringify(this.value)); } catch { /* not saved */ } + for (const listener of this.listeners) listener(this.value); + } + on(listener: Listener): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } +} + +function browserStore(): Store | undefined { + try { return typeof localStorage === 'undefined' ? undefined : localStorage; } catch { return undefined; } +} + +export const preferences = new PreferenceStore(browserStore()); + +/** The hero's rim: the actors' warm one, or the brighter cool outline when the player asked for it. */ +export const HERO_OUTLINE = { colour: 0xe8fbff, strength: 0.95 } as const; +export function heroRim(prefs: Preferences, actor: { colour: number; strength: number }) { + return prefs.heroOutline ? HERO_OUTLINE : actor; +} diff --git a/game/src/player/player.ts b/game/src/player/player.ts index 41f5ebc..9c9e9d8 100644 --- a/game/src/player/player.ts +++ b/game/src/player/player.ts @@ -19,6 +19,7 @@ import { Interaction } from './interaction'; import { HeroSlices, PonchoSway } from './idle-life'; import { parseAtmosphere, type Wind } from '../stage/atmosphere/config'; import { ACTOR_RIM, addRim } from '../stage/atmosphere/pipeline'; +import { heroRim, preferences } from '../core/preferences'; import { dataKey } from '../boot/manifest'; import { GuardPush } from '../combat/guard'; import { bindings, CODES, physicalKeys, type Action } from './physicalKeys'; @@ -93,7 +94,11 @@ export class Player { this.hurt = new HurtAnimation(scene.cache.json.get('hero-hurt.anims') ?? {}, scene.cache.json.get('hero-death.anims') ?? {}); this.sprite = scene.add.sprite(this.body.x, this.body.y, 'hero-move', 'idle-00').setOrigin(0.5, 1).setDepth(100); this.slices = new HeroSlices(scene, this.sprite); - for (const sprite of [this.sprite, ...this.slices.parts]) addRim(sprite, ACTOR_RIM.colour, ACTOR_RIM.strength); + // The actors' warm rim, or a brighter cool outline when the player asked for it (Settings, playtest R1). + const rim = () => { const r = heroRim(preferences.state, ACTOR_RIM); for (const s of [this.sprite, ...this.slices.parts]) addRim(s, r.colour, r.strength); }; + rim(); + const offRim = preferences.on(rim); + scene.events.once('shutdown', offRim); this.blades = scene.add.graphics().setDepth(101); // After every scene update (the chair may swap the hero's frame): place the feet in the ground. const settle = () => this.settle(); diff --git a/game/src/ui/menu.ts b/game/src/ui/menu.ts index 70da42e..082de91 100644 --- a/game/src/ui/menu.ts +++ b/game/src/ui/menu.ts @@ -1,6 +1,7 @@ import { debugStore, ZOOMS } from '../core/debug'; import { LEVELS, nextSpot, type Spot } from '../stage/teleports'; import type { RegionTarget } from '../stage/swap'; +import { preferences } from '../core/preferences'; import { ACTIONS, bindings, CODES, keyName, type Action } from '../player/physicalKeys'; import { download, falls } from '../stage/fallCapture'; @@ -164,6 +165,7 @@ export class GameMenu { field('Volume', volume), field('Zoom', zoom), field('Screen', full), + field('Hero', check(preferences.state.heroOutline, 'Brighter outline – easier to see against the leaves', on => preferences.set({ heroOutline: on }))), field('Keys', this.keys()), ]; }