diff --git a/fktry/src/main.ts b/fktry/src/main.ts index 0db4806..b51ae67 100644 --- a/fktry/src/main.ts +++ b/fktry/src/main.ts @@ -24,6 +24,86 @@ const ui = createUI(); const screen = createScreenFX(); const audio = createAudioFX(); +/** + * Radio → RadioControl adapter (round-5 review fix: the dial drove nothing). + * Two problems it solves, both integration seams between LANE-SCREEN's engine and + * LANE-UI's dock: + * 1. TIMING — the dock resolves its control ONCE, inside ui.init(), before any user + * gesture has built the audio graph. This adapter EXISTS immediately (so the + * init-time probe finds a real subscribe()) and lazily attaches to the engine + * when the first gesture creates it, replaying state to subscribers. + * 2. SHAPE + UNITS — UI speaks RadioControl {setFrequency(MHz), volume(), mute(), + * state.freqMHz, state.station:name}; the engine speaks Radio {setFrequency(native + * kHz for AM/SW), setVolume(), setMuted(), state.frequency, state.locked:id}. + */ +function createSpeakerAdapter() { + type EngineRadio = NonNullable>; + let engine: EngineRadio | null = null; + let names = new Map(); + const subs = new Set<(s: any) => void>(); + // pre-engine local state so the dock behaves before the first gesture + let local = { freqMHz: 88.0, band: 'FM' as 'AM' | 'FM' | 'SW', volume: 0.7, muted: false, station: null as string | null }; + + const toMHz = (band: string, f: number) => (band === 'FM' ? f : f / 1000); + const toNative = (band: string, mhz: number) => (band === 'FM' ? mhz : mhz * 1000); + const translate = (s: any) => ({ + freqMHz: toMHz(s.band, s.frequency), + band: s.band, + volume: s.volume, + muted: s.muted, + station: s.locked ? (names.get(s.locked) ?? s.locked) : null, + }); + + const control = { + setFrequency(mhz: number) { + local.freqMHz = mhz; + if (engine) engine.setFrequency(toNative(engine.getState().band, mhz)); + else notifyLocal(); + }, + setBand(band: 'AM' | 'FM' | 'SW') { + local.band = band; + if (engine) engine.setBand(band); + else notifyLocal(); + }, + volume(v: number) { + local.volume = Math.max(0, Math.min(1, v)); + if (engine) engine.setVolume(local.volume); + else notifyLocal(); + }, + mute(on: boolean) { + local.muted = on; + if (engine) engine.setMuted(on); + else notifyLocal(); + }, + getState() { + return engine ? translate(engine.getState()) : { ...local }; + }, + subscribe(cb: (s: any) => void) { + subs.add(cb); + cb(control.getState()); + return () => subs.delete(cb); + }, + }; + function notifyLocal() { for (const cb of subs) cb({ ...local }); } + + return { + control, + attach() { + const r = getRadio(); + if (!r || engine) return; + engine = r; + names = new Map(r.listStations().map((s) => [s.id, s.name])); + // carry any pre-gesture dial fiddling into the engine + r.setBand(local.band); + r.setFrequency(toNative(local.band, local.freqMHz)); + r.setVolume(local.volume); + r.setMuted(local.muted); + r.subscribe((s) => { const t = translate(s); for (const cb of subs) cb(t); }); + }, + }; +} +const speaker = createSpeakerAdapter(); + // v4 bridge: _sel keeps the legacy {def,dir} shape (including the deprecated // '__remove' sentinel) so unmigrated lanes keep working; selection()/setSelection() // are the typed protocol. The sentinel path is deleted in v5. @@ -52,15 +132,18 @@ const bus: UIBus & { _sel: { def: string; dir: Dir } | null; _state: SelectionSt async function boot() { sim.init(data, 1337); + // publish the speaker handshake BEFORE ui.init — the dock resolves its control + // exactly once, during init, and must find a live-shaped RadioControl there. + (window as any).__fktrySpeaker = { radio: speaker.control }; await renderer.init(document.getElementById('game')!, data); ui.init(document.getElementById('ui')!, data, bus); screen.init(document.getElementById('screen') as HTMLCanvasElement, data); audio.init(data); - // browsers gate AudioContext on a user gesture — unlock on the first one + // browsers gate AudioContext on a user gesture — unlock on the first one, + // then attach the adapter to the freshly built engine. addEventListener('pointerdown', () => { audio.unlock(); - // UI's tuner dock probes window.__fktrySpeaker.radio (its documented handshake). - (window as any).__fktrySpeaker = { radio: getRadio() }; + speaker.attach(); }, { once: true }); // click-to-place / hover ghost glue diff --git a/fktry/src/screen/baseTexture.ts b/fktry/src/screen/baseTexture.ts index cb03e72..c789ee1 100644 --- a/fktry/src/screen/baseTexture.ts +++ b/fktry/src/screen/baseTexture.ts @@ -130,12 +130,33 @@ export function createBaseFeed(): BaseFeed { // Chyron band. Carries 'NOTHING IS WRONG' while idle, or a radio subtitle when tuned. // Painted AFTER the era treatment so captions stay legible on any substrate. + // Round-5 review fix (orchestrator): a single un-wrapped fillText showed ~51 chars + // and 29 of THE STANDARD's 30 lines clipped mid-sentence. "THE SCREEN tells you + // what it says" has to mean ALL of what it says: word-wrap to two lines, stepping + // the font down until it fits. if (state.chyron) { + const maxW = BASE_W - 36; + let lines: string[] = []; + let px = 20; + for (const size of [20, 16, 13]) { + px = size; + g.font = `bold ${px}px 'Courier New', monospace`; + lines = []; + let cur = ''; + for (const word of state.chyron.split(' ')) { + const next = cur ? cur + ' ' + word : word; + if (g.measureText(next).width <= maxW) cur = next; + else { if (cur) lines.push(cur); cur = word; } + } + if (cur) lines.push(cur); + if (lines.length <= 2) break; + } + const lineH = px + 6; + const bandH = 10 + lines.length * lineH; g.fillStyle = 'rgba(8,8,12,0.82)'; - g.fillRect(0, BASE_H * 0.5, BASE_W, 34); + g.fillRect(0, BASE_H * 0.5, BASE_W, bandH); g.fillStyle = '#d8ffd8'; - g.font = "bold 20px 'Courier New', monospace"; - g.fillText(state.chyron, 18, BASE_H * 0.5 + 24); + lines.forEach((line, i) => g.fillText(line, 18, BASE_H * 0.5 + px + 4 + i * lineH)); } }