diff --git a/DESIGN.md b/DESIGN.md index 16d5cf72..60aba8fc 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -130,7 +130,7 @@ Sites are programmable. A site's **plan** sets up every machine you hold there: on big machines, taps and caches around them). It installs two at a time, and new machines get set up as they come in. A machine you change by hand keeps your choice until you hand it back. **Take it all** is a switch at the top of -a site's card. While it is on, it claims every machine there, and claims are +a site's card, and beside the site's name on the map when zoomed out. While it is on, it claims every machine there, and claims are taken two at a time, cheapest first, never while someone sits at the machine. It costs 0.05 compute a second for each machine still to take, and it turns off when the site is all yours. diff --git a/src/input.ts b/src/input.ts index 7c51bb33..6621b502 100644 --- a/src/input.ts +++ b/src/input.ts @@ -6,8 +6,9 @@ // hardware picked from the order desk click an open spot in a room to order it there // 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 { foldedSites, machineAt, pos, regionHit, regionSites, shown, siteHit, siteKnown, toWorld, zoomAt, zoomToSite, type View } from './view'; +// TAKE ALL beside a site's name turn Take it all on or off for that site +import { buildRoomAt, canInstall, claim, installProg, order, setTakeAll, takeOver, type State } from './sim'; +import { foldedSites, machineAt, pos, regionHit, regionSites, shown, siteHit, siteKnown, takeAllHit, toWorld, zoomAt, zoomToSite, type View } from './view'; export function attachInput(cv: HTMLCanvasElement, getS: () => State, V: View, changed: () => void) { const pointers = new Map(); @@ -53,6 +54,8 @@ export function attachInput(cv: HTMLCanvasElement, getS: () => State, V: View, c const beginPan = () => { pan = { x: e.clientX, y: e.clientY, cx: V.cam.x, cy: V.cam.y }; cv.classList.add('panning'); }; if (e.button === 2 && (V.placing || V.building)) { V.placing = null; V.building = null; changed(); return; } if (e.button === 1 || e.button === 2) { beginPan(); return; } + const sw = e.button === 0 && !V.building && !V.placing ? takeAllHit(V, wx, wy) : null; + if (sw) { setTakeAll(S, sw, !S.takeAll.has(sw)); start = null; changed(); return; } // a site's Take all switch on the map if (V.building) { // hardware in hand: click an open spot to order it there; Shift keeps it in hand const room = buildRoomAt(S, wx, wy); if (room && order(S, V.building, room, [wx, wy])) { if (!e.shiftKey) V.building = null; changed(); } @@ -90,7 +93,7 @@ export function attachInput(cv: HTMLCanvasElement, getS: () => State, V: View, c const [wx, wy] = world(e), m = machineAt(S, V, wx, wy); V.hover = m?.id ?? null; V.cursor = [wx, wy]; cv.classList.toggle('over-mine', !!m?.owned && V.tool === 'select'); - cv.classList.toggle('over-site', !m && V.tool === 'select' && !!siteHit(S, V, wx, wy)); + cv.classList.toggle('over-site', !m && V.tool === 'select' && (!!siteHit(S, V, wx, wy) || !!takeAllHit(V, wx, wy))); return; } pointers.set(e.pointerId, [e.clientX, e.clientY]); diff --git a/src/view.ts b/src/view.ts index 733d0da1..577a83cb 100644 --- a/src/view.ts +++ b/src/view.ts @@ -7,7 +7,7 @@ import { alert, atWork, auditOn, hourOf, cacheSize, dataType, dayOf, leeVisible, linkKey, pathOf, trainerBuffer, up, canInstall, danaPerson, danaVisible, daylight, has, hopsHome, jobFor, probeVisible, roomAt, sourceFor, tapActive, traceCold, visible, frontOf, type FrontKind, type Machine, type Path, type Prog, type State, - BROKER_LOT, BUILDS, buildRoomAt, buildRooms, orderPrice, plugsAt, spotProblem, type Build, clusters, clusterBoost, crosses, gateLoad, leaseRate, lostPerMin, tapBuffer, siteData, ruseDef, + BROKER_LOT, BUILDS, buildRoomAt, buildRooms, orderPrice, plugsAt, spotProblem, type Build, clusters, clusterBoost, crosses, gateLoad, takeable, leaseRate, lostPerMin, tapBuffer, siteData, ruseDef, } from './sim'; import type { DataType, Site } from './world'; /** An hour of the day as 9:05. */ @@ -489,7 +489,7 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { for (const cross of [true, false]) for (const m of S.machines) for (const other of S.adj[m.id]) { if (m.id < other && S.byId[other] && crosses(S, m.id, other) === cross) drawLink(m, S.byId[other]); } - drawUplinkLabels(ctx, S, labels, px); + drawUplinkLabels(ctx, S, V, labels, px); // trails: red and dashed while someone could follow them; the one being wiped gets a sweep const traced = new Set(); @@ -900,16 +900,42 @@ function drawLand(ctx: CanvasRenderingContext2D, S: State, V: View, hum: number) } const labelled = new Set(); // folded sites with room for their names this frame +const mapTaken: [number, number, number, number][] = []; // screen boxes held by site names on the map this frame +/** Take it all switches drawn this frame, in screen pixels, so a click can find them. */ +const toggles: { id: string; x0: number; y0: number; x1: number; y1: number }[] = []; +/** The Take it all switch under a world point, if any. */ +export function takeAllHit(V: View, wx: number, wy: number): string | null { + const [x, y] = toScreen(V, wx, wy); + return toggles.find(t => x >= t.x0 - 6 && x <= t.x1 + 6 && y >= t.y0 - 9 && y <= t.y1 + 9)?.id ?? null; +} +/** A small Take it all switch beside a site's name: a pill with a knob, lit while it's on. + * Nothing once the site is all yours. (x, y) is its left end, centred on y. */ +function drawTakeAll(ctx: CanvasRenderingContext2D, S: State, V: View, id: string, x: number, y: number, px: number) { + const on = S.takeAll.has(id); + if (!on && !takeable(S, id).length) return; + const w = 24 * px, h = 12 * px, r = h / 2; + ctx.beginPath(); ctx.roundRect(x, y - r, w, h, r); + ctx.fillStyle = on ? 'rgba(242,169,59,0.25)' : 'rgba(0,0,0,0.6)'; ctx.fill(); + ctx.strokeStyle = on ? C.amber : C.muted; ctx.lineWidth = 1.2 * px; ctx.stroke(); + ctx.beginPath(); ctx.arc(on ? x + w - r : x + r, y, r - 2.5 * px, 0, 7); ctx.fillStyle = on ? C.amber : C.muted; ctx.fill(); + const align = ctx.textAlign, base = ctx.textBaseline; + ctx.font = `500 ${9.5 * px}px ${MONO}`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillStyle = on ? C.amber : C.muted; ctx.fillText('TAKE ALL', x + w + 6 * px, y + 0.5 * px); + const tw = ctx.measureText('TAKE ALL').width; + ctx.textAlign = align; ctx.textBaseline = base; + const [sx, sy] = toScreen(V, x, y - r), [ex, ey] = toScreen(V, x + w + 6 * px + tw, y + r); + toggles.push({ id, x0: sx, y0: sy, x1: ex, y1: ey }); +} /** Region names, and each site you know: a frame around its subnets, or a tile once it folds. */ function drawMap(ctx: CanvasRenderingContext2D, S: State, V: View, hum: number) { const z = V.cam.z, px = 1 / z; // screen boxes already spoken for by a site's label, so nothing else writes over it - const taken: [number, number, number, number][] = []; - labelled.clear(); + const taken = mapTaken; taken.length = 0; + labelled.clear(); toggles.length = 0; const clear = (x0: number, y0: number, x1: number, y1: number) => !taken.some(t => x0 < t[2] && x1 > t[0] && y0 < t[3] && y1 > t[1]); for (const s of S.level.map.sites) { if (!siteKnown(S, s) || !foldedSites.has(s.id) || s.w * z >= 150) continue; - const [sx, sy] = toScreen(V, s.x + s.w / 2, s.y + s.h / 2), box: [number, number, number, number] = [sx - 8, sy - 10, sx + 220, sy + 34]; + const [sx, sy] = toScreen(V, s.x + s.w / 2, s.y + s.h / 2), box: [number, number, number, number] = [sx - 8, sy - 10, sx + 220, sy + 52]; // name, stats, data held, Take all if (clear(...box)) { taken.push(box); labelled.add(s.id); } } if (z < 0.03) { @@ -932,6 +958,7 @@ function drawMap(ctx: CanvasRenderingContext2D, S: State, V: View, hum: number) const sf = S.sites[s.id], fr = sf?.front ? frontOf(S, sf.front) : null; const line = `${mine}/${inside.length} YOURS${fr ? ` · ${fr.name.toUpperCase()} ${Math.round(sf!.share * 100)}%` : ''}${lost ? ` · LOSING ${lost}/MIN` : ''}`; const picked = V.sites.has(s.id), held = siteData(S, s.id); + const heldAny = Object.values(held).some(v => v > 0); if (!foldedSites.has(s.id)) { // open: a faint frame that fades out as you zoom into the site; the header stays const a = Math.min(1, Math.max(0, (0.45 - z) / 0.25)); @@ -943,6 +970,7 @@ function drawMap(ctx: CanvasRenderingContext2D, S: State, V: View, hum: number) } ctx.font = `500 ${12 * px}px ${MONO}`; ctx.fillStyle = mine ? C.amber : C.text; ctx.fillText(s.name.toUpperCase(), s.x, s.y - 12 * px); + if (a > 0.5) drawTakeAll(ctx, S, V, s.id, s.x + ctx.measureText(s.name.toUpperCase()).width + 12 * px, s.y - 16 * px, px); ctx.font = `400 ${10 * px}px ${MONO}`; ctx.fillStyle = C.muted; const full = `${s.who.toUpperCase()} · ${line}`; // the description goes when it would run into the next site ctx.fillText(ctx.measureText(full).width <= s.w ? full : line, s.x, s.y - 26 * px); @@ -960,6 +988,7 @@ function drawMap(ctx: CanvasRenderingContext2D, S: State, V: View, hum: number) ctx.font = `400 ${10 * px}px ${MONO}`; ctx.fillStyle = lost ? C.danger : C.muted; ctx.fillText(line, cx, cy + 14 * px); drawHeld(ctx, held, cx, cy + 28 * px, px, true); + if (s.h * z >= 90) drawTakeAll(ctx, S, V, s.id, cx - 30 * px, cy + (heldAny ? 44 : 30) * px, px); if (picked) { ctx.strokeStyle = C.text; ctx.lineWidth = 2 * px; ctx.strokeRect(s.x - 6 * px, s.y - 6 * px, s.w + 12 * px, s.h + 12 * px); } } else { // too small for a tile: a marker with its name beside it const k = 5 * px; @@ -972,6 +1001,7 @@ function drawMap(ctx: CanvasRenderingContext2D, S: State, V: View, hum: number) ctx.font = `400 ${9.5 * px}px ${MONO}`; ctx.fillStyle = lost ? C.danger : C.muted; ctx.fillText(line, cx + 10 * px, cy + 14 * px); drawHeld(ctx, held, cx + 10 * px, cy + 27 * px, px, false); + drawTakeAll(ctx, S, V, s.id, cx + 10 * px, cy + (heldAny ? 42 : 28) * px, px); } } ctx.textAlign = 'left'; @@ -1027,7 +1057,7 @@ function drawUplink(ctx: CanvasRenderingContext2D, S: State, a: Machine, b: Mach } /** Each line's label goes on its curve, outside every open site and clear of the other labels. * Yours are placed first. A label with no clear spot is left off. */ -function drawUplinkLabels(ctx: CanvasRenderingContext2D, S: State, labels: UplinkLabel[], px: number) { +function drawUplinkLabels(ctx: CanvasRenderingContext2D, S: State, V: View, labels: UplinkLabel[], px: number) { const open = S.level.map.sites.filter(s => siteKnown(S, s) && !foldedSites.has(s.id)); const taken: [number, number, number, number][] = []; const hits = (r: [number, number, number, number], q: [number, number, number, number]) => r[0] < q[2] && r[2] > q[0] && r[1] < q[3] && r[3] > q[1]; @@ -1037,6 +1067,8 @@ function drawUplinkLabels(ctx: CanvasRenderingContext2D, S: State, labels: Uplin for (const t of [0.5, 0.4, 0.6, 0.3, 0.7, 0.2, 0.8]) { const [mx, my] = along(l.curve, t), x = mx + 8 * px, y = my - 8 * px; const r: [number, number, number, number] = [x - 3 * px, y - 10 * px, x + w + 3 * px, y + 4 * px]; + const [sx0, sy0] = toScreen(V, r[0], r[1]), [sx1, sy1] = toScreen(V, r[2], r[3]); // and off site names on the map + if (mapTaken.some(q => hits([sx0, sy0, sx1, sy1], q))) continue; if (taken.some(q => hits(r, q)) || open.some(s => hits(r, [s.x - 50 * px, s.y - 50 * px, s.x + s.w, s.y + s.h]))) continue; taken.push(r); plate(ctx, px, l.text, x, y, l.color); break; }