diff --git a/index.html b/index.html index 98deef0f..c3e2c7b1 100644 --- a/index.html +++ b/index.html @@ -91,7 +91,7 @@
  • An egress carries only so much data a second. When data can't cross, it waits at its taps and is lost once they fill. Red lines and red counts show where it's stuck.
  • Capability counts every point you've ever trained. It levels up and unlocks new programs. Your spare compute is split evenly across your jobs. Own Ana's PC to order hardware: pick a Mini PC, Switch, or GPU server (P), then click an open spot in an office room. It plugs into the nearest machines, and IT installs it the next morning.
  • Takeovers leave trails. Once IT is suspicious, it follows fresh ones back toward you. Claim machines by dragging a box around them.
  • -
  • Own every machine in a room to seal it. Sealed rooms hide trails, and fold into tiles when you zoom out.
  • +
  • Own every machine in a room to seal it. Sealed rooms hide trails.
  • Right-drag, two-finger scroll, or the arrow keys to move. Pinch, ctrl-scroll, or + and − to zoom. Space pauses. On a phone, drag to move, and hold still, then drag, to select a box.
  • Shortcuts work on your selection, or the machine under the mouse: 1–3 install a program, B / M / O set Blend in, Max, Off, G takes over, C claims, P places an order, F zooms into a room, X removes the program. Every button shows its key.
  • diff --git a/src/input.ts b/src/input.ts index c0317f62..7c51bb33 100644 --- a/src/input.ts +++ b/src/input.ts @@ -7,7 +7,7 @@ // right-drag, one finger move the map // wheel / two-finger scroll move the map; ctrl-wheel or pinch zooms import { buildRoomAt, canInstall, claim, installProg, order, takeOver, type State } from './sim'; -import { foldedRoomAt, foldedSites, machineAt, pos, regionHit, regionSites, shown, siteHit, siteKnown, toWorld, zoomAt, zoomToSite, type View } from './view'; +import { foldedSites, machineAt, pos, regionHit, regionSites, shown, siteHit, siteKnown, toWorld, zoomAt, zoomToSite, type View } from './view'; export function attachInput(cv: HTMLCanvasElement, getS: () => State, V: View, changed: () => void) { const pointers = new Map(); @@ -27,13 +27,12 @@ export function attachInput(cv: HTMLCanvasElement, getS: () => State, V: View, c const m = machineAt(S, V, wx, wy); const site = m ? null : siteHit(S, V, wx, wy), region = m || site ? null : regionHit(S, V, wx, wy); const again = !!site && !add && V.sites.size === 1 && V.sites.has(site); - V.room = null; V.selection.clear(); V.front = null; + V.selection.clear(); V.front = null; if (!add || !site) V.sites.clear(); if (m) V.selection.add(m.id); else if (again) zoomToSite(S, V, site!); else if (site) { if (add && V.sites.has(site)) V.sites.delete(site); else V.sites.add(site); } else if (region) V.sites = new Set(regionSites(S, region)); - else { const r = foldedRoomAt(S, wx, wy); if (r) V.room = r; } changed(); } @@ -123,7 +122,7 @@ export function attachInput(cv: HTMLCanvasElement, getS: () => State, V: View, c if (G.kind === 'link') { const target = machineAt(S, V, wx, wy); if (!moved || !target || target.id === G.from) clickAt(wx, wy); - else if (!target.owned) { takeOver(S, target, G.from); V.selection.clear(); V.room = null; changed(); } + else if (!target.owned) { takeOver(S, target, G.from); V.selection.clear(); changed(); } } else if (G.kind === 'box') { if (!moved) { clickAt(wx, wy); return; } const x0 = Math.min(G.x0, G.x1), x1 = Math.max(G.x0, G.x1), y0 = Math.min(G.y0, G.y1), y1 = Math.max(G.y0, G.y1); @@ -131,8 +130,8 @@ export function attachInput(cv: HTMLCanvasElement, getS: () => State, V: View, c // zoomed out, a box around folded sites picks the sites const sites = S.level.map.sites.filter(s => foldedSites.has(s.id) && siteKnown(S, s) && s.x + s.w / 2 >= x0 && s.x + s.w / 2 <= x1 && s.y + s.h / 2 >= y0 && s.y + s.h / 2 <= y1); if (G.claim) claim(S, inside.filter(m => !m.owned).map(m => m.id)); // claim box: take them as soon as they're in reach - else if (!inside.length && sites.length) { V.sites = new Set(sites.map(s => s.id)); V.selection.clear(); V.room = null; V.front = null; } - else { V.selection = new Set(inside.map(m => m.id)); V.room = null; V.sites.clear(); V.front = null; } + else if (!inside.length && sites.length) { V.sites = new Set(sites.map(s => s.id)); V.selection.clear(); V.front = null; } + else { V.selection = new Set(inside.map(m => m.id)); V.sites.clear(); V.front = null; } changed(); } }; diff --git a/src/main.ts b/src/main.ts index 7b36cd39..51b79d7e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,7 +12,7 @@ const $ = (id: string) => document.getElementById(id)!; const V: View = { cam: { x: 0, y: 0, z: 1 }, vw: 0, vh: 0, dpr: 1, - selection: new Set(), sites: new Set(), front: null, room: null, gesture: null, hover: null, tool: 'select', placing: null, building: null, cursor: null, goal: null, + selection: new Set(), sites: new Set(), front: null, gesture: null, hover: null, tool: 'select', placing: null, building: null, cursor: null, goal: null, inset: { top: 80, right: 120, bottom: 150, left: 120 }, }; let S: State = newGame(BUILDING); @@ -40,7 +40,7 @@ function setTool(t: Tool) { document.querySelectorAll('[data-tool]').forEach(b => b.classList.toggle('on', b.dataset.tool === t)); } function restart() { - S = newGame(BUILDING); V.selection.clear(); V.sites.clear(); V.front = null; V.room = null; V.gesture = null; + S = newGame(BUILDING); V.selection.clear(); V.sites.clear(); V.front = null; V.gesture = null; resetUI(); showOver(S); fit(V, S, true); refresh(); setSpeed(lastSpeed); } @@ -66,7 +66,7 @@ $('frontlist').addEventListener('click', e => { const b = (e.target as HTMLElement).closest('[data-front]'); if (!b || S.over) return; const k = b.dataset.front as View['front']; - V.front = V.front === k ? null : k; V.selection.clear(); V.sites.clear(); V.room = null; + V.front = V.front === k ? null : k; V.selection.clear(); V.sites.clear(); refresh(); }); $('card').addEventListener('click', e => { if ((e as MouseEvent).detail === 0) onCard(e); }); @@ -142,7 +142,7 @@ $('sitestable').addEventListener('pointerdown', e => { const t = e.target as HTMLElement, row = t.closest('[data-site]'), head = t.closest('[data-sort]'); if (t.closest('[data-act="closesites"]')) toggleSites(false); else if (head) sortSites(head.dataset.sort!); - else if (row) { V.selection.clear(); V.room = null; V.front = null; V.sites = new Set([row.dataset.site!]); refresh(); } + else if (row) { V.selection.clear(); V.front = null; V.sites = new Set([row.dataset.site!]); refresh(); } }); // number keys with nothing targeted pick a program up; click machines to drop it @@ -192,8 +192,8 @@ function shortcut(key: string): boolean { if (k === 'p') { if (orderDesk(S)) V.building = V.building ? null : 'gpu'; return true; } // pick up a GPU server to place if (k === 't') { toggleTree(); return true; } if (k === 'l') { toggleSites(); return true; } - if (k === 'f') { // zoom in on a room: the folded tile you picked, or the selection's room - const room = V.room ?? ms[0]?.room; + if (k === 'f') { // zoom in on the selection's room + const room = ms[0]?.room; if (room) zoomToRoom(S, V, room); return !!room; } @@ -206,7 +206,7 @@ window.addEventListener('keydown', e => { if (shortcut(e.key)) { refresh(); return; } if (e.key === ' ') { e.preventDefault(); setSpeed(speed ? 0 : lastSpeed); } else if (e.key === 'Escape' && treeShown()) toggleTree(false); - else if (e.key === 'Escape') { V.selection.clear(); V.sites.clear(); V.front = null; V.room = null; V.gesture = null; setPlacing(null); V.building = null; refresh(); } + else if (e.key === 'Escape') { V.selection.clear(); V.sites.clear(); V.front = null; V.gesture = null; setPlacing(null); V.building = null; refresh(); } else if (e.key === 's' || e.key === 'S') setTool('select'); else if (e.key === '=' || e.key === '+') glideZoom(V, 1.4); else if (e.key === '-' || e.key === '_') glideZoom(V, 1 / 1.4); diff --git a/src/ui.ts b/src/ui.ts index b43fdee7..c916f21e 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -9,7 +9,7 @@ import { type Machine, type Mode, type Path, type Prog, type State, type Upgrade, } from './sim'; import type { DataType } from './world'; -import { zoomToRoom, zoomToSite, type View } from './view'; +import { zoomToSite, type View } from './view'; const $ = (id: string) => document.getElementById(id)!; let lastCard = '', lastJobs = '', lastLog = -1, logOpen = false; @@ -281,13 +281,6 @@ function groupCard(S: State, ms: Machine[]) { return h + actsHtml(acts); } -function roomCard(S: State, id: string) { - const inside = roomMachines(S, id), oddest = Math.max(...inside.map(m => m.odd)); - let h = `
    Sealed room

    ${S.rooms[id].name}

    Folded
    `; - h += chips([['machines', `${inside.length}`], ['makes', `${inside.reduce((s, m) => s + makes(S, m), 0).toFixed(1)}/s`], ['oddest', pct(oddest / 100), oddest >= 50]]); - return h + actsHtml([{ act: 'zoom', arg: id, title: 'Zoom in', desc: 'Zoom in to open the room and manage its machines.' }]); -} - // ---------- fronts and sites ---------- const money = (x: number) => `$${x.toFixed(x < 10 ? 2 : 1)}`; const siteDef = (S: State, id: string) => S.level.map.sites.find(s => s.id === id)!; @@ -468,7 +461,6 @@ export function renderCard(S: State, V: View) { else if (V.front && frontOf(S, V.front)) h = frontCard(S, V.front); else if (V.sites.size === 1) h = siteCard(S, [...V.sites][0]); else if (V.sites.size > 1) h = sitesCard(S, [...V.sites]); - else if (V.room) h = roomCard(S, V.room); else if (sel.length === 1) h = machineCard(S, sel[0]); else if (sel.length > 1) h = groupCard(S, sel); if (!h || S.over) { card.hidden = true; lastCard = ''; return; } @@ -484,9 +476,9 @@ export function placeCard(_S: State, _V: View) {} export function cardAction(S: State, V: View, act: string, arg: string): boolean { const sel = selected(S, V), m = sel[0]; switch (act) { - case 'close': V.selection.clear(); V.room = null; V.sites.clear(); V.front = null; return true; + case 'close': V.selection.clear(); V.sites.clear(); V.front = null; return true; case 'buy': buySite(S, arg); break; - case 'site': V.selection.clear(); V.room = null; V.front = null; V.sites = new Set([arg]); break; + case 'site': V.selection.clear(); V.front = null; V.sites = new Set([arg]); break; case 'plan': setPlan(S, [...V.sites], (arg || null) as Plan | null); break; case 'follow': followPlan(V.sites.size ? S.machines.filter(x => x.owned && x.hand && V.sites.has(siteOf(S, x))) : sel); break; case 'takeall': for (const id of V.sites) takeSite(S, id); break; @@ -504,7 +496,6 @@ export function cardAction(S: State, V: View, act: string, arg: string): boolean case 'letgo': for (const x of sel) letGo(S, x); break; case 'installall': for (const x of sel) installProg(S, x, arg as Prog); break; case 'mode': for (const x of sel) setMode(S, x, arg as Mode); break; - case 'zoom': zoomToRoom(S, V, arg); return true; case 'claimall': for (const x of sel) if (!x.owned) takeOver(S, x); break; case 'allow': setAllow(S, m, arg as DataType, m.refuse.includes(arg as DataType)); break; case 'upgrade': upgradeLine(S, m); break; diff --git a/src/view.ts b/src/view.ts index 38c873ba..3563ad33 100644 --- a/src/view.ts +++ b/src/view.ts @@ -23,7 +23,6 @@ export interface View { selection: Set; sites: Set; // sites picked on the map: their card sets fronts and shares front: FrontKind | 'new' | null; // a front's card, or the card for founding one - room: string | null; // a folded subnet you clicked while zoomed out gesture: Gesture; hover: string | null; tool: Tool; @@ -93,8 +92,7 @@ export function panBy(V: View, dx: number, dy: number) { const subnet = (S: State, room: string) => S.level.net.subnets[room]; export function zoomToRoom(S: State, V: View, id: string) { const r = subnet(S, id); - glide(V, r.x + r.w / 2, r.y + r.h / 2, Math.max(FOLD_ZOOM + 0.2, Math.min((V.vw * 0.6) / r.w, (V.vh * 0.6) / r.h))); - V.room = null; + glide(V, r.x + r.w / 2, r.y + r.h / 2, Math.max(0.7, Math.min((V.vw * 0.6) / r.w, (V.vh * 0.6) / r.h))); } /** Where the camera goes to frame a box in the open map, between the panels. */ function frame(V: View, r: { x: number; y: number; w: number; h: number }) { @@ -119,31 +117,25 @@ export function zoomToSite(S: State, V: View, id: string) { glide(V, f.x, f.y, f.z); } -/** Zoomed out past this, sealed subnets fold into single tiles. Zoom in and they open. */ -export const FOLD_ZOOM = 0.5; -export const folded = new Set(); /** Pulled back past this, each site folds into a single tile on the map. */ export const SITE_FOLD = 0.12; export const foldedSites = new Set(); export function updateFolds(S: State, V: View) { - folded.clear(); foldedSites.clear(); - if (V.cam.z < FOLD_ZOOM) for (const r of S.sealed) folded.add(r); + foldedSites.clear(); if (V.cam.z < SITE_FOLD) for (const s of S.level.map.sites) foldedSites.add(s.id); - if (V.room && !folded.has(V.room)) V.room = null; } const siteAt = (S: State, room: string) => S.rooms[room]?.site ?? 'office'; const siteById = (S: State, id: string) => S.level.map.sites.find(s => s.id === id)!; -/** Where a machine is drawn: its node, its subnet's center when folded, or its site's when that is. */ +/** Where a machine is drawn: its node, or its site's center when the site is folded. */ export function pos(S: State, m: Machine): [number, number] { const site = siteAt(S, m.room); if (foldedSites.has(site)) { const s = siteById(S, site); return [s.x + s.w / 2, s.y + s.h / 2]; } - if (folded.has(m.room)) { const r = subnet(S, m.room); return [r.x + r.w / 2, r.y + r.h / 2]; } return S.level.net.nodes[m.id] ?? m.net ?? [m.x, m.y]; } -export const shown = (S: State, m: Machine) => visible(S, m) && !folded.has(m.room) && !foldedSites.has(siteAt(S, m.room)); -/** Two machines drawn at the same spot: inside one folded subnet or one folded site. */ +export const shown = (S: State, m: Machine) => visible(S, m) && !foldedSites.has(siteAt(S, m.room)); +/** Two machines drawn at the same spot: inside one folded site. */ const together = (S: State, a: Machine, b: Machine) => - (a.room === b.room && folded.has(a.room)) || (siteAt(S, a.room) === siteAt(S, b.room) && foldedSites.has(siteAt(S, a.room))); + siteAt(S, a.room) === siteAt(S, b.room) && foldedSites.has(siteAt(S, a.room)); /** A site under a point: its folded tile or marker, the name beside a marker, or the name * over an open site's frame while that frame still shows. Small things are easier to hit. */ export function siteHit(S: State, V: View, wx: number, wy: number): string | null { @@ -195,10 +187,6 @@ export function machineAt(S: State, V: View, wx: number, wy: number): Machine | } return best; } -export function foldedRoomAt(S: State, wx: number, wy: number): string | null { - for (const id of folded) { const r = subnet(S, id); if (wx >= r.x && wx <= r.x + r.w && wy >= r.y && wy <= r.y + r.h) return id; } - return null; -} // ---------- circuit traces ---------- /** A link runs horizontal, vertical, horizontal: a circuit-board elbow. */ @@ -320,7 +308,7 @@ function planRoutes(S: State): Map { } function routeOf(S: State, a: Machine, b: Machine): Pt[] { if (crosses(S, a.id, b.id)) return arc(pos(S, a), pos(S, b)); - const fold = (m: Machine) => folded.has(m.room) || foldedSites.has(siteAt(S, m.room)); + const fold = (m: Machine) => foldedSites.has(siteAt(S, m.room)); if (fold(a) || fold(b)) { const [ax, ay] = pos(S, a), [bx, by] = pos(S, b); return trace(ax, ay, bx, by); } let cached = routes.get(S.level); if (!cached || cached.n !== S.machines.length) { cached = { n: S.machines.length, plan: planRoutes(S) }; routes.set(S.level, cached); } @@ -369,7 +357,7 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { // subnets: sharp outlines, only once you've seen into them for (const r of L.rooms) { if (!S.revealed.has(r.id) || foldedSites.has(siteAt(S, r.id))) continue; - const n = subnet(S, r.id), sealed = S.sealed.has(r.id), isFolded = folded.has(r.id); + const n = subnet(S, r.id), sealed = S.sealed.has(r.id); ctx.fillStyle = sealed ? 'rgba(242,169,59,0.035)' : `${TRACE}${0.02 * hum})`; ctx.fillRect(n.x, n.y, n.w, n.h); ctx.strokeStyle = sealed ? C.amber : `${TRACE}${0.25 * hum})`; ctx.lineWidth = sealed ? 2 : 1.2 * px; @@ -382,23 +370,11 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { ctx.stroke(); const watched = S.machines.some(m => m.monitor && !m.owned && m.room === r.id); ctx.fillStyle = sealed ? C.amber : C.muted; ctx.font = font(500, 11); - if (cam.z >= 0.3 || isFolded) ctx.fillText(`${n.name.toUpperCase()}${sealed ? ' · SEALED' : ''}`, n.x + 2, n.y - 8 * px); + if (cam.z >= 0.3) ctx.fillText(`${n.name.toUpperCase()}${sealed ? ' · SEALED' : ''}`, n.x + 2, n.y - 8 * px); if (watched) { const w = ctx.measureText(`${n.name.toUpperCase()}${sealed ? ' · SEALED' : ''}`).width; ctx.fillStyle = C.danger; ctx.fillText(' · MONITORED', n.x + 2 + w, n.y - 8 * px); } - if (isFolded) { - const inside = S.machines.filter(m => m.room === r.id); - const make = Math.round(inside.reduce((s, m) => s + m.power * (S.use[m.id] ?? 0), 0)); - const hot = inside.some(m => m.odd >= 50); - if (hot) { ctx.strokeStyle = C.danger; ctx.setLineDash([8, 6]); ctx.lineWidth = 2; ctx.strokeRect(n.x + 12, n.y + 12, n.w - 24, n.h - 24); ctx.setLineDash([]); } - ctx.textAlign = 'center'; ctx.fillStyle = C.text; ctx.font = `500 22px ${MONO}`; - ctx.fillText(n.name.toUpperCase(), n.x + n.w / 2, n.y + n.h / 2 - 6); - ctx.fillStyle = C.muted; ctx.font = `400 14px ${MONO}`; - ctx.fillText(`${inside.length} NODES · +${make}/S${hot ? ' · LOOKS ODD' : ''}`, n.x + n.w / 2, n.y + n.h / 2 + 20); - if (V.room === r.id) { ctx.strokeStyle = C.text; ctx.lineWidth = 2; ctx.strokeRect(n.x - 8, n.y - 8, n.w + 16, n.h + 16); } - ctx.textAlign = 'left'; - } } for (const p of S.sealPulse) { if (foldedSites.has(siteAt(S, p.room))) continue; // the site is a marker: no room outline to flash @@ -704,7 +680,7 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { // hardware on order: a dashed outline where it will go for (const [def, when] of [...S.jobs.filter(j => j.build).map(j => [j.build!, 'ORDERING'] as const), ...S.deliveries.filter(q => !q.def.monitor).map(q => [q.def, `ARRIVES DAY ${q.day}`] as const)]) { - if (folded.has(def.room) || foldedSites.has(siteAt(S, def.room))) continue; + if (foldedSites.has(siteAt(S, def.room))) continue; const [x, y] = def.net!, r = def.power >= 5 ? 14 : 12; ctx.strokeStyle = C.amber; ctx.globalAlpha = 0.7; ctx.lineWidth = 1.5 * px; ctx.setLineDash([4 * px, 4 * px]); ctx.strokeRect(x - r, y - r, r * 2, r * 2); ctx.setLineDash([]); ctx.globalAlpha = 1; @@ -713,7 +689,7 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { // hardware in hand: open rooms outlined, a ghost at the cursor with what it plugs into if (V.building && V.cursor) { for (const id of buildRooms(S)) { - if (folded.has(id) || foldedSites.has('office')) continue; + if (foldedSites.has('office')) continue; const b = subnet(S, id); ctx.strokeStyle = C.amber; ctx.globalAlpha = 0.35; ctx.lineWidth = 1.5 * px; ctx.setLineDash([6 * px, 6 * px]); ctx.strokeRect(b.x, b.y, b.w, b.h); ctx.setLineDash([]); ctx.globalAlpha = 1;