[ui] click-to-inspect via bus.pickTile; drop the TAB fallback and fixture
Contract request 1 landed, so the inspector opens the way it always should have: click a machine. pick.ts loses the window.__fktryRenderer probe and its apology; index.ts loses the TAB manifest cycle that stood in for it. ?uifixture and fixture.ts are deleted too. The sim is real now — a brownout is something you cause by building an extractor with no generator, which is a better demo than the fake one ever was. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
34e2bb3fcf
commit
491fbf2d68
@ -1,135 +0,0 @@
|
||||
/**
|
||||
* LANE-UI — dev-only fake sim, for verifying the HUD before LANE-SIM lands.
|
||||
*
|
||||
* LANE-SIM's stub reports zero entities, zero bandwidth and no events forever, so with
|
||||
* the real snapshot there is literally nothing on screen to check: an empty inspector
|
||||
* and a 0/0 meter prove nothing. This module synthesises a plausible snapshot from the
|
||||
* real tick so every surface can be seen doing its job in a browser.
|
||||
*
|
||||
* INERT unless the URL says `?uifixture`. It never dispatches Commands and never touches
|
||||
* another lane; when the real sim arrives, drop the query param (or this file).
|
||||
*
|
||||
* See NOTES — this is scaffolding, not a feature.
|
||||
*/
|
||||
import type { GameData, EntityState, SimEvent, SimSnapshot } from '../contracts';
|
||||
|
||||
export function fixtureEnabled(): boolean {
|
||||
return typeof location !== 'undefined' && new URLSearchParams(location.search).has('uifixture');
|
||||
}
|
||||
|
||||
export interface Fixture {
|
||||
snapshot(paused: boolean): SimSnapshot;
|
||||
/** Events for ticks that have elapsed since the last call. */
|
||||
drain(): SimEvent[];
|
||||
}
|
||||
|
||||
const SHIP_EVERY = 90; // one melt every 3s at 30tps
|
||||
const MS_PER_TICK = 1000 / 30;
|
||||
/** Never replay more than this many ticks of events in one drain (backgrounded tab). */
|
||||
const MAX_CATCHUP = 300;
|
||||
|
||||
export function createFixture(data: GameData): Fixture {
|
||||
const entities: EntityState[] = data.machines.map((m, i) => ({
|
||||
id: i + 1,
|
||||
def: m.id,
|
||||
pos: { x: i * 3 - 9, y: i % 2 === 0 ? -2 : 2 },
|
||||
dir: (i % 4) as 0 | 1 | 2 | 3,
|
||||
recipe: m.recipes[0] ?? null,
|
||||
progress: 0,
|
||||
inputBuf: {},
|
||||
outputBuf: {},
|
||||
jammed: null,
|
||||
heat: 0,
|
||||
}));
|
||||
|
||||
const recipes = new Map(data.recipes.map((r) => [r.id, r]));
|
||||
const jamVictim = entities.find((e) => e.def === 'mosh-reactor') ?? entities[0];
|
||||
let lastTick = -1;
|
||||
let brownoutOn = false;
|
||||
|
||||
// Wall clock, not the passed-in sim tick. rAF stops in a backgrounded tab, so a
|
||||
// tick-driven fixture crawls and you can never actually watch the brownout arrive.
|
||||
// This is a fake sim for eyeballing the HUD; nothing here has to be deterministic.
|
||||
const t0 = performance.now();
|
||||
const nowTick = () => Math.floor((performance.now() - t0) / MS_PER_TICK);
|
||||
|
||||
function brownoutAt(tick: number) {
|
||||
return tick % 600 > 480; // ~4s of pain every 20s
|
||||
}
|
||||
|
||||
return {
|
||||
snapshot(paused) {
|
||||
const tick = nowTick();
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const e = entities[i];
|
||||
const r = e.recipe ? recipes.get(e.recipe) : null;
|
||||
e.progress = r ? ((tick + i * 13) % r.ticks) / r.ticks : 0;
|
||||
// The mosh reactor gets to be the one that's unhappy, for the amber readout.
|
||||
e.jammed = e.def === 'mosh-reactor' && tick % 300 > 240 ? 'output buffer full' : null;
|
||||
e.heat = e.def === 'quantizer' ? Math.min(1, (tick % 900) / 900) : 0;
|
||||
if (r) {
|
||||
e.inputBuf = {};
|
||||
for (const [item, need] of Object.entries(r.inputs)) {
|
||||
e.inputBuf[item] = (tick + i) % (need + 2);
|
||||
}
|
||||
e.outputBuf = {};
|
||||
for (const item of Object.keys(r.outputs)) {
|
||||
e.outputBuf[item] = Math.floor(((tick + i * 7) % 200) / 40);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shipped = Math.floor(tick / SHIP_EVERY);
|
||||
const out = brownoutAt(tick);
|
||||
const draw = 6 + (out ? 22 : Math.abs(((tick / 60) % 4) - 2) * 7);
|
||||
|
||||
const shippedTotal: Record<string, number> = {};
|
||||
if (shipped > 0) {
|
||||
shippedTotal.melt = shipped;
|
||||
shippedTotal['anchor-slab'] = shipped;
|
||||
}
|
||||
|
||||
return {
|
||||
tick,
|
||||
paused,
|
||||
entities,
|
||||
beltItems: [],
|
||||
bandwidth: { gen: 20, draw, stored: out ? 0 : 40 + Math.sin(tick / 40) * 20, brownout: out },
|
||||
shippedTotal,
|
||||
activeCommission: data.commissions[0]?.id ?? null,
|
||||
commissionProgress: { melt: shipped },
|
||||
};
|
||||
},
|
||||
|
||||
drain() {
|
||||
const tick = nowTick();
|
||||
const events: SimEvent[] = [];
|
||||
if (lastTick < 0) {
|
||||
lastTick = tick;
|
||||
return events;
|
||||
}
|
||||
if (tick - lastTick > MAX_CATCHUP) lastTick = tick - MAX_CATCHUP;
|
||||
for (let t = lastTick + 1; t <= tick; t++) {
|
||||
if (t % SHIP_EVERY === 0 && t > 0) {
|
||||
events.push({ kind: 'shipped', item: 'melt', count: 1, tick: t });
|
||||
// Recurring rather than one-shot: the stamp is a 1.8s animation, and a
|
||||
// fixture that fires it once at tick 90 is unobservable in practice.
|
||||
if (t % (SHIP_EVERY * 3) === 0) {
|
||||
const c = data.commissions[0];
|
||||
if (c) events.push({ kind: 'commissionDone', commission: c.id, tick: t });
|
||||
}
|
||||
}
|
||||
if (t % 300 === 241 && jamVictim) {
|
||||
events.push({ kind: 'jammed', entity: jamVictim.id, reason: 'output buffer full', tick: t });
|
||||
}
|
||||
const out = brownoutAt(t);
|
||||
if (out !== brownoutOn) {
|
||||
brownoutOn = out;
|
||||
events.push({ kind: 'brownout', on: out, tick: t });
|
||||
}
|
||||
}
|
||||
lastTick = tick;
|
||||
return events;
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -10,10 +10,9 @@
|
||||
import type { GameData, MachineDef, SimEvent, SimSnapshot, UI, UIBus } from '../contracts';
|
||||
import { createBuildBar } from './buildbar';
|
||||
import { createFax } from './fax';
|
||||
import { createFixture, fixtureEnabled, type Fixture } from './fixture';
|
||||
import { createHotkeys } from './hotkeys';
|
||||
import { createInspector } from './inspector';
|
||||
import { createRoster, entityAt, resolvePickTile, syncRoster, type PickTile } from './pick';
|
||||
import { createRoster, entityAt, syncRoster } from './pick';
|
||||
import { createSelection } from './selection';
|
||||
import { injectStyle } from './style';
|
||||
import { createTopStrip } from './topstrip';
|
||||
@ -27,35 +26,16 @@ export function createUI(): UI {
|
||||
let fax: ReturnType<typeof createFax>;
|
||||
let toasts: ReturnType<typeof createToasts>;
|
||||
|
||||
let pickTile: PickTile | null = null;
|
||||
let fixture: Fixture | null = null;
|
||||
|
||||
/** Our own copy of the entity list; safe to read between frames. See pick.ts. */
|
||||
const roster = createRoster();
|
||||
let paused = false;
|
||||
let manifestIndex = -1;
|
||||
|
||||
function rosterIndexOf(id: number): number {
|
||||
for (let i = 0; i < roster.length; i++) if (roster.entries[i].id === id) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
function machineName(entityId: number): string {
|
||||
const i = rosterIndexOf(entityId);
|
||||
if (i < 0) return `UNIT ${entityId}`;
|
||||
return machines.get(roster.entries[i].def)?.name ?? `UNIT ${entityId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* TEMPORARY: nothing reachable from the UI can turn a click into a tile (see pick.ts),
|
||||
* so TAB walks the unit manifest instead. That keeps the inspector reachable with no
|
||||
* console, which is the round's definition of done. Delete once the CONTRACT REQUEST
|
||||
* for `UIBus.pickTile` lands and clicking a machine works.
|
||||
*/
|
||||
function cycleManifest() {
|
||||
if (roster.length === 0) return;
|
||||
manifestIndex = (manifestIndex + 1) % roster.length;
|
||||
inspector.open(roster.entries[manifestIndex].id);
|
||||
for (let i = 0; i < roster.length; i++) {
|
||||
const r = roster.entries[i];
|
||||
if (r.id === entityId) return machines.get(r.def)?.name ?? `UNIT ${entityId}`;
|
||||
}
|
||||
return `UNIT ${entityId}`;
|
||||
}
|
||||
|
||||
return {
|
||||
@ -66,20 +46,17 @@ export function createUI(): UI {
|
||||
const sel = createSelection(bus);
|
||||
top = createTopStrip(data);
|
||||
build = createBuildBar(data, sel);
|
||||
inspector = createInspector(data);
|
||||
inspector = createInspector(data, bus);
|
||||
fax = createFax(data);
|
||||
toasts = createToasts();
|
||||
|
||||
root.append(top.el, build.el, inspector.el, fax.el, toasts.el);
|
||||
|
||||
if (fixtureEnabled()) {
|
||||
fixture = createFixture(data);
|
||||
console.info('[ui] fixture mode: the HUD is being driven by a fake sim (?uifixture)');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ keyboard
|
||||
const keys = createHotkeys();
|
||||
for (let n = 1; n <= 9; n++) keys.bind(String(n), () => build.selectSlot(n));
|
||||
keys.bind('[', () => build.pageBy(-1));
|
||||
keys.bind(']', () => build.pageBy(1));
|
||||
keys.bind('r', () => sel.rotate());
|
||||
keys.bind('escape', () => {
|
||||
// One key, two jobs, in the order the player expects: drop the tool first.
|
||||
@ -87,7 +64,6 @@ export function createUI(): UI {
|
||||
else inspector.close();
|
||||
});
|
||||
keys.bind(' ', () => bus.dispatch({ kind: 'setPaused', paused: !paused }));
|
||||
keys.bind('tab', cycleManifest);
|
||||
keys.attach();
|
||||
|
||||
// ------------------------------------------------------------------- pointer
|
||||
@ -107,26 +83,16 @@ export function createUI(): UI {
|
||||
if (e.button !== 0 || !inWorld(e.target)) return;
|
||||
if (sel.get()) return; // placing, not inspecting — that click is main.ts's
|
||||
|
||||
pickTile ??= resolvePickTile(); // renderer may register late
|
||||
const tile = pickTile?.(e.clientX, e.clientY);
|
||||
const tile = bus.pickTile(e.clientX, e.clientY);
|
||||
if (!tile) return;
|
||||
|
||||
const hit = entityAt(roster, machines, tile);
|
||||
if (hit === null) {
|
||||
inspector.close();
|
||||
} else {
|
||||
inspector.open(hit);
|
||||
manifestIndex = rosterIndexOf(hit);
|
||||
}
|
||||
if (hit === null) inspector.close();
|
||||
else inspector.open(hit);
|
||||
});
|
||||
},
|
||||
|
||||
update(snap: SimSnapshot, events: SimEvent[]) {
|
||||
if (fixture) {
|
||||
snap = fixture.snapshot(snap.paused);
|
||||
events = fixture.drain();
|
||||
}
|
||||
|
||||
paused = snap.paused;
|
||||
syncRoster(roster, snap);
|
||||
|
||||
|
||||
@ -1,27 +1,11 @@
|
||||
/**
|
||||
* LANE-UI — figuring out which unit the player clicked.
|
||||
*
|
||||
* PROBLEM: `UIBus` gives us `dispatch` + `selectedBuild`, and `ui.init(root, data, bus)`
|
||||
* never sees the Renderer — so the UI has no access to `renderer.pickTile`, which is the
|
||||
* only thing that knows the camera. CONTRACT REQUEST filed in NOTES (add `pickTile` to
|
||||
* UIBus, or pass the renderer to `ui.init`).
|
||||
*
|
||||
* Until then: we opportunistically use `window.__fktryRenderer.pickTile` if LANE-RENDER
|
||||
* happens to expose one, and the inspector otherwise falls back to TAB-cycling the unit
|
||||
* manifest (see index.ts). Tile→entity resolution below is ours either way.
|
||||
* `bus.pickTile` (contracts v2) turns a client point into a tile; tile→entity resolution
|
||||
* below is ours. Together they are click-to-inspect.
|
||||
*/
|
||||
import type { Dir, MachineDef, SimSnapshot, Vec2 } from '../contracts';
|
||||
|
||||
export type PickTile = (clientX: number, clientY: number) => Vec2 | null;
|
||||
|
||||
export function resolvePickTile(): PickTile | null {
|
||||
const r = (globalThis as any).__fktryRenderer;
|
||||
if (r && typeof r.pickTile === 'function') {
|
||||
return (x, y) => r.pickTile(x, y) ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattened copy of the entities in a snapshot: exactly the fields the UI needs between
|
||||
* frames (hit-testing a click, naming a jammed unit in a toast).
|
||||
|
||||
Loading…
Reference in New Issue
Block a user