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;