Deepgrain Foundry: move from a claude.ai artifact to a local repository
The game as of artifact version 24, ready for local development with coding agents. - Source split from three large files into 52 small ones under src/sim, src/render and src/ui, joined in the order in src/manifest.json by build.py. The joined code is unchanged apart from comments; every headless suite gives the same output, and screenshots in both looks are pixel-identical to before. - Light tidying with no change in behaviour: one movement scan loop, shared hazard-stripe and belt shader helpers, unused code removed, a shared check helper for the test suites. - World files can be exported as an ordinary download outside claude.ai. - Tests: broken old scripts removed, experiments moved to tools/, test/run_all.py added. Tools: shot.py (repeatable screenshots), where.py, map.py. - Docs: AGENTS.md, CLAUDE.md, NOTES.md (the owner's to-do inbox), docs/PROJECT_MAP.md, DESIGN.md, TESTING.md, systems notes, the owner's requests and an agent log. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9kLmYADKSEjuwUN3rFxDA
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
// Previews drawn under the cursor: build ghosts, drill aim and pulses, selections, paste.
|
||||
// ---------- Overlay previews ----------
|
||||
function blockRectPx(bx, by, bw, bh) { const q = worldToScreenPx(bx * BS, by * BS); return [q.x, q.y, bw * BS * R.zp, bh * BS * R.zp]; }
|
||||
function drawGrid(cbx, cby, radius) {
|
||||
const ctx = R.ctx, zp = R.zp, s = BS * zp;
|
||||
ctx.save();
|
||||
ctx.lineWidth = 1;
|
||||
for (let dy = -radius; dy <= radius; dy++) for (let dx = -radius; dx <= radius; dx++) {
|
||||
const d = Math.hypot(dx, dy) / radius; if (d > 1) continue;
|
||||
const [x, y] = blockRectPx(cbx + dx, cby + dy, 1, 1);
|
||||
ctx.strokeStyle = `rgba(236,226,210,${(0.16 * (1 - d)).toFixed(3)})`;
|
||||
ctx.strokeRect(Math.round(x) + 0.5, Math.round(y) + 0.5, s, s);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
function previewColor(code, v) {
|
||||
if (code === BP_WALL) { const m = WALL_OF[v]; return `${COLR[m]},${COLG[m]},${COLB[m]}`; }
|
||||
if (code === BP_FILTER) return '96,100,110';
|
||||
return { [BP_BELT]: '110,114,124', [BP_TUBE]: '120,132,150', [BP_SHAFT]: '160,118,70', [BP_GRATE]: '170,90,66', [BP_GATE]: '160,84,64', [BP_PIPE]: '176,112,72', [BP_PUMP]: '104,122,142', [BP_OUTLET]: '170,106,66' }[code] || '120,200,190';
|
||||
}
|
||||
// Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking.
|
||||
// Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines
|
||||
// run on unbroken from one cell to the next and look like real fractures, not a pattern.
|
||||
const CRACK_N = [makeNoise(mulberry32(0xc7ac1)), makeNoise(mulberry32(0xc7ac2)), makeNoise(mulberry32(0xc7ac3))];
|
||||
const CRACK_LV = [{ s: 1 / 7, k: 0.1, w: 0.2 }, { s: 1 / 3.6, k: 0.42, w: 0.14 }, { s: 1 / 2, k: 0.72, w: 0.09 }];
|
||||
function crackSeg(ctx, x, y, n, sc, zp, oy) {
|
||||
const v = (cx, cy) => n(cx * sc + 0.37, cy * sc + 0.61);
|
||||
const a = v(x, y), b = v(x + 1, y), c = v(x + 1, y + 1), d = v(x, y + 1);
|
||||
const pts = [];
|
||||
if ((a < 0) !== (b < 0)) pts.push(x + a / (a - b), y);
|
||||
if ((b < 0) !== (c < 0)) pts.push(x + 1, y + b / (b - c));
|
||||
if ((d < 0) !== (c < 0)) pts.push(x + d / (d - c), y + 1);
|
||||
if ((a < 0) !== (d < 0)) pts.push(x, y + a / (a - d));
|
||||
for (let k = 0; k + 3 < pts.length; k += 4) {
|
||||
ctx.moveTo((pts[k] - R.camX) * zp, (pts[k + 1] - R.camY) * zp + oy);
|
||||
ctx.lineTo((pts[k + 2] - R.camX) * zp, (pts[k + 3] - R.camY) * zp + oy);
|
||||
}
|
||||
}
|
||||
function drawCracks() {
|
||||
const ctx = R.ctx, zp = R.zp;
|
||||
if (zp < 2.5) return;
|
||||
const x0 = Math.max(0, Math.floor(R.camX)), y0 = Math.max(0, Math.floor(R.camY));
|
||||
const x1 = Math.min(W - 1, Math.ceil(R.camX + R.canvas.width / zp)), y1 = Math.min(H - 1, Math.ceil(R.camY + R.canvas.height / zp));
|
||||
const cells = [];
|
||||
for (let y = y0; y <= y1; y++) { const row = y * W; for (let x = x0; x <= x1; x++) { const dp = digp[row + x]; if (dp && PH[mat[row + x]] === PH_SOLID) cells.push(x, y, dp / 255); } }
|
||||
if (!cells.length) return;
|
||||
ctx.save(); ctx.lineCap = 'round';
|
||||
for (let l = 0; l < CRACK_LV.length; l++) {
|
||||
const L = CRACK_LV[l];
|
||||
// a soft lit lip just below each crack, then the dark crack itself
|
||||
for (const pass of [0, 1]) {
|
||||
ctx.beginPath();
|
||||
for (let k = 0; k < cells.length; k += 3) if (cells[k + 2] >= L.k) crackSeg(ctx, cells[k], cells[k + 1], CRACK_N[l], L.s, zp, pass ? 0 : Math.max(1, zp * 0.12));
|
||||
ctx.strokeStyle = pass ? `rgba(18,12,10,${0.78 - l * 0.12})` : 'rgba(255,240,220,0.13)';
|
||||
ctx.lineWidth = Math.max(1, zp * L.w * (pass ? 1 : 1.3));
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
// The pulses in flight: a wavefront running out from the face of the bot, fading as it goes
|
||||
function pulseRGB(tune) {
|
||||
if (!tune) return '150,215,255';
|
||||
return `${Math.round((COLR[tune] + 255) / 2)},${Math.round((COLG[tune] + 255) / 2)},${Math.round((COLB[tune] + 255) / 2)}`;
|
||||
}
|
||||
// screen points across a fan (mouth at the bot, or out at distance sd along each ray)
|
||||
function fanPoints(ox, oy, ang, Wd, sd, n) {
|
||||
const pts = [];
|
||||
for (let k = 0; k <= n; k++) { const r = pulseRay(ox, oy, ang, Wd, -1 + 2 * k / n); pts.push(worldToScreenPx(r.x + r.ux * sd, r.y + r.uy * sd)); }
|
||||
return pts;
|
||||
}
|
||||
function drawDrillPulses() {
|
||||
if (!drillPulses.length) return;
|
||||
const ctx = R.ctx, zp = R.zp;
|
||||
ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
|
||||
for (const q of drillPulses) {
|
||||
const Wd = PULSE_W[q.w], rr = Math.min(q.r, q.range), a = Math.max(0, 1 - q.r / (q.range + 3)), col = pulseRGB(q.tune);
|
||||
if (rr < 3) continue;
|
||||
const front = fanPoints(q.ox, q.oy, q.ang, Wd, rr, 10), back = fanPoints(q.ox, q.oy, q.ang, Wd, Math.max(2.5, rr - 3), 10);
|
||||
const line = (pts) => { ctx.beginPath(); pts.forEach((pt, k) => k ? ctx.lineTo(pt.x, pt.y) : ctx.moveTo(pt.x, pt.y)); };
|
||||
// a faint band behind the front, then the bright front itself
|
||||
ctx.fillStyle = `rgba(${col},${(0.08 * a).toFixed(3)})`;
|
||||
line(front); for (let k = back.length - 1; k >= 0; k--) ctx.lineTo(back[k].x, back[k].y); ctx.closePath(); ctx.fill();
|
||||
ctx.strokeStyle = `rgba(${col},${(0.2 * a).toFixed(3)})`; ctx.lineWidth = Math.max(3, zp * 1.3); line(front); ctx.stroke();
|
||||
ctx.strokeStyle = `rgba(${col},${(0.85 * a).toFixed(3)})`; ctx.lineWidth = Math.max(1.5, zp * 0.3); line(front); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
// With the drill in hand: the fan a pulse will sweep (as wide as the bot where it leaves it),
|
||||
// out to where it stops, and a crosshair
|
||||
function drawDrillAim(ax, ay) {
|
||||
const ctx = R.ctx, zp = R.zp;
|
||||
const o = drillOrigin(), c = worldToScreenPx(o.x, o.y), p = worldToScreenPx(ax + 0.5, ay + 0.5);
|
||||
const d = Math.hypot(ax + 0.5 - o.x, ay + 0.5 - o.y), ang = Math.atan2(ay + 0.5 - o.y, ax + 0.5 - o.x);
|
||||
const Wd = PULSE_W[GAME.drillW], range = Math.max(3, Math.min(drillReach(GAME.drillW), d + 3));
|
||||
const ok = inDrillReach(ax, ay, GAME.drillW), col = ok ? pulseRGB(GAME.drillTune) : '239,106,85';
|
||||
const mouth = fanPoints(o.x, o.y, ang, Wd, 2.5, 1), far = fanPoints(o.x, o.y, ang, Wd, range, 10);
|
||||
ctx.save();
|
||||
// the outline gets a dark under-stroke so it reads on bright sky as well as dark rock
|
||||
const outline = () => { ctx.beginPath(); ctx.moveTo(mouth[0].x, mouth[0].y); for (const pt of far) ctx.lineTo(pt.x, pt.y); ctx.lineTo(mouth[1].x, mouth[1].y); ctx.closePath(); };
|
||||
ctx.fillStyle = `rgba(${col},0.06)`; outline(); ctx.fill();
|
||||
ctx.setLineDash([zp * 0.8, zp * 0.8]); ctx.lineDashOffset = -(R.frame * 0.3) % (zp * 1.6);
|
||||
ctx.strokeStyle = 'rgba(10,14,20,0.28)'; ctx.lineWidth = Math.max(2, zp * 0.3); ctx.stroke();
|
||||
ctx.strokeStyle = `rgba(${col},0.6)`; ctx.lineWidth = Math.max(1, zp * 0.14); ctx.stroke();
|
||||
ctx.setLineDash([]); ctx.lineDashOffset = 0;
|
||||
// the emitter glows across the bot's face while it fires
|
||||
const hot = Math.max(0, 1 - (tick - lastPulseTick) / PULSE_EVERY);
|
||||
if (GAME.lmb && hot > 0) {
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.strokeStyle = `rgba(${col},${(0.7 * hot).toFixed(3)})`; ctx.lineWidth = Math.max(2, zp * 0.6); ctx.lineCap = 'round';
|
||||
ctx.beginPath(); ctx.moveTo(mouth[0].x, mouth[0].y); ctx.lineTo(mouth[1].x, mouth[1].y); ctx.stroke();
|
||||
const g = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, zp * 3.5);
|
||||
g.addColorStop(0, `rgba(${col},${(0.35 * hot).toFixed(3)})`); g.addColorStop(1, `rgba(${col},0)`);
|
||||
ctx.fillStyle = g; ctx.fillRect(c.x - zp * 3.5, c.y - zp * 3.5, zp * 7, zp * 7);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
ctx.strokeStyle = ok ? 'rgba(239,230,216,0.85)' : 'rgba(239,106,85,0.85)'; ctx.lineWidth = Math.max(1, zp * 0.2);
|
||||
const k = zp * 0.9;
|
||||
ctx.beginPath(); ctx.moveTo(p.x - k * 1.6, p.y); ctx.lineTo(p.x - k * 0.5, p.y); ctx.moveTo(p.x + k * 0.5, p.y); ctx.lineTo(p.x + k * 1.6, p.y);
|
||||
ctx.moveTo(p.x, p.y - k * 1.6); ctx.lineTo(p.x, p.y - k * 0.5); ctx.moveTo(p.x, p.y + k * 0.5); ctx.lineTo(p.x, p.y + k * 1.6); ctx.stroke();
|
||||
if (GAME.drillTune) { const sz = Math.max(6, zp * 1.4); ctx.fillStyle = rgbOf(GAME.drillTune); ctx.fillRect(p.x + k * 1.8, p.y - k * 1.8 - sz, sz, sz); ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.strokeRect(p.x + k * 1.8 + 0.5, p.y - k * 1.8 - sz + 0.5, sz - 1, sz - 1); }
|
||||
ctx.restore();
|
||||
}
|
||||
function drawPreviews() {
|
||||
const ctx = R.ctx, zp = R.zp;
|
||||
const t = toolId(), ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
|
||||
const p = worldToScreenPx(ax, ay);
|
||||
ctx.save();
|
||||
const reachOk = inReach(ax, ay);
|
||||
const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld(), pasting = !sel && GAME.paste && GAME.clip;
|
||||
if (!reachOk && t !== 'drill' && !sel) {
|
||||
const c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2);
|
||||
ctx.strokeStyle = 'rgba(239,106,85,0.35)'; ctx.setLineDash([zp * 2, zp * 2]); ctx.lineWidth = Math.max(1, zp * 0.3);
|
||||
ctx.beginPath(); ctx.arc(c.x, c.y, REACH * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
|
||||
}
|
||||
if (t === 'drill' && !sel && !pasting && !inDrillReach(ax, ay, GAME.drillW)) {
|
||||
const c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2);
|
||||
ctx.strokeStyle = 'rgba(239,106,85,0.45)'; ctx.setLineDash([zp * 2, zp * 2]); ctx.lineWidth = Math.max(1, zp * 0.3);
|
||||
ctx.beginPath(); ctx.arc(c.x, c.y, drillReach(GAME.drillW) * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
|
||||
}
|
||||
drawDrillPulses();
|
||||
if (t === 'drill' && !sel && !pasting) drawDrillAim(ax, ay);
|
||||
if (sel) drawSelection(sel, ax, ay);
|
||||
else if (pasting) drawPastePreview();
|
||||
else if (t === 'vacuum' || t === 'ignite') {
|
||||
const r = t === 'ignite' ? 1 : GAME.brush[GAME.tool];
|
||||
const reachOk = inReach(ax, ay);
|
||||
const blowing = t === 'vacuum' && GAME.rmb && !GAME.rmbConsumed;
|
||||
ctx.strokeStyle = !reachOk ? 'rgba(239,106,85,0.8)' : t === 'drill' ? 'rgba(239,230,216,0.8)' : blowing ? 'rgba(255,210,122,0.9)' : t === 'vacuum' ? 'rgba(92,186,166,0.95)' : 'rgba(255,154,60,0.9)';
|
||||
ctx.lineWidth = Math.max(1, zp * 0.25);
|
||||
if (t === 'vacuum') ctx.setLineDash([zp, zp * 0.6]);
|
||||
ctx.beginPath(); ctx.arc(p.x + zp / 2, p.y + zp / 2, (r + 0.5) * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
|
||||
if (t === 'vacuum') {
|
||||
// what the right button would blow, next to the cursor; what the left is sucking, while it sucks
|
||||
const sz = Math.max(6, zp * 1.6), qx = p.x + (r + 1.2) * zp, qy = p.y - (r + 1.2) * zp;
|
||||
const swatches = GAME.lmb && GAME.vacFilter > 0 ? [GAME.vacFilter] : GAME.blowMix ? mixList() : GAME.dropMat && bag[GAME.dropMat] > 0 ? [GAME.dropMat] : [];
|
||||
swatches.slice(0, 4).forEach((m, k) => { ctx.fillStyle = rgbOf(m); ctx.fillRect(qx + k * (sz + 2), qy, sz, sz); });
|
||||
if (blowing || (GAME.lmb && GAME.vacFilter)) {
|
||||
// faint line from the nozzle: the path the grains take
|
||||
const c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2);
|
||||
ctx.strokeStyle = blowing ? 'rgba(255,210,122,0.22)' : 'rgba(92,186,166,0.22)'; ctx.lineWidth = Math.max(1, zp * (r + 0.5) * 0.6);
|
||||
ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(p.x + zp / 2, p.y + zp / 2); ctx.stroke();
|
||||
}
|
||||
}
|
||||
} else if (t === 'build') {
|
||||
const P = piece(), b = aimBlock();
|
||||
drawGrid(b.bx, b.by, 9);
|
||||
let cells = [], col = '120,200,190';
|
||||
if (P.kind === 'block') {
|
||||
const v = pieceV(P);
|
||||
col = previewColor(P.code, v);
|
||||
cells = GAME.drag ? (modShift() ? blockRect(GAME.drag.bx, GAME.drag.by, b.bx, b.by) : blockLine(GAME.drag.bx, GAME.drag.by, b.bx, b.by)) : [[b.bx, b.by]];
|
||||
const dd = GAME.drag ? dragDirFor(P, GAME.drag, b) : -1;
|
||||
if (dd !== GAME.dragDir) { GAME.dragDir = dd; hudDirty = true; }
|
||||
const dIdx = dd >= 0 ? dd : (GAME.pieceDir[P.id] || 0);
|
||||
const afford = canAfford(pieceCostNow(P), cells.length);
|
||||
for (const [bx, by] of cells) {
|
||||
const err = canPlaceBlock(bx, by), rb = by * BW + bx;
|
||||
const swap = GAME.ctrl && !!blockP[rb] && /already built/.test(err) && !blockIs(rb, P.code, v, pieceExtra(P));
|
||||
const ghost = !blockInReach(bx, by) && (!err || /bar/.test(err)); // out of reach: it will be a ghost
|
||||
const ok = (!err || swap) && !ghost && afford;
|
||||
const [x, y, w, h] = blockRectPx(bx, by, 1, 1);
|
||||
ctx.fillStyle = ghost ? 'rgba(90,190,230,0.22)' : ok ? (swap ? 'rgba(255,190,90,0.5)' : `rgba(${col},0.55)`) : 'rgba(239,106,85,0.35)';
|
||||
ctx.fillRect(x, y, w, h);
|
||||
ctx.strokeStyle = ghost ? 'rgba(90,190,230,0.8)' : ok ? 'rgba(236,226,210,0.8)' : 'rgba(239,106,85,0.9)'; ctx.lineWidth = Math.max(1, zp * 0.25);
|
||||
if (ghost) ctx.setLineDash([zp, zp * 0.7]);
|
||||
ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); ctx.setLineDash([]);
|
||||
if (P.dirs && (P.id === 'belt' || P.id === 'tube' || P.filter || P.id === 'pump' || P.id === 'outlet')) {
|
||||
// a chevron on every block, pointing the way it will carry (or pump, or pour)
|
||||
const [dx, dy] = P.id === 'tube' ? [[0, -1], [1, 0], [0, 1], [-1, 0]][dIdx] : P.id === 'pump' ? PUMP_V[dIdx] : P.id === 'outlet' ? OUTLET_V[dIdx] : [[-1, 0], [1, 0]][dIdx];
|
||||
const cx = x + w / 2, cy = y + h / 2, k = w * 0.22, px_ = -dy, py_ = dx;
|
||||
ctx.strokeStyle = 'rgba(20,16,12,0.55)'; ctx.lineWidth = Math.max(2, zp * 0.9); ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
||||
const chev = () => { ctx.beginPath(); ctx.moveTo(cx - dx * k * 0.6 + px_ * k, cy - dy * k * 0.6 + py_ * k); ctx.lineTo(cx + dx * k * 0.7, cy + dy * k * 0.7); ctx.lineTo(cx - dx * k * 0.6 - px_ * k, cy - dy * k * 0.6 - py_ * k); ctx.stroke(); };
|
||||
chev(); ctx.strokeStyle = 'rgba(255,224,160,0.98)'; ctx.lineWidth = Math.max(1.2, zp * 0.45); chev();
|
||||
}
|
||||
}
|
||||
// dragging a line of belts, filters or tubes: one long arrow over it, the way it will run
|
||||
if (GAME.drag && dd >= 0 && cells.length > 1 && !modShift()) {
|
||||
const [x0, y0, w0, h0] = blockRectPx(cells[0][0], cells[0][1], 1, 1), [x1, y1, w1, h1] = blockRectPx(cells[cells.length - 1][0], cells[cells.length - 1][1], 1, 1);
|
||||
let sx = x0 + w0 / 2, sy = y0 + h0 / 2, ex = x1 + w1 / 2, ey = y1 + h1 / 2;
|
||||
const [dx, dy] = P.id === 'tube' ? [[0, -1], [1, 0], [0, 1], [-1, 0]][dd] : [[-1, 0], [1, 0]][dd];
|
||||
if ((ex - sx) * dx + (ey - sy) * dy < 0) { const tx = sx, ty = sy; sx = ex; sy = ey; ex = tx; ey = ty; }
|
||||
const off = BS * zp * 1.0, hx = ex + dx * BS * zp * 0.55, hy = ey + dy * BS * zp * 0.55, ah = Math.max(8, BS * zp * 0.5);
|
||||
const ox = dy ? off : 0, oy = dx ? off : 0;
|
||||
const arrow = () => { ctx.beginPath(); ctx.moveTo(sx - dx * BS * zp * 0.3 + ox, sy - dy * BS * zp * 0.3 + oy); ctx.lineTo(hx + ox, hy + oy); ctx.moveTo(hx - dx * ah - dy * ah * 0.6 + ox, hy - dy * ah - dx * ah * 0.6 + oy); ctx.lineTo(hx + ox, hy + oy); ctx.lineTo(hx - dx * ah + dy * ah * 0.6 + ox, hy - dy * ah + dx * ah * 0.6 + oy); ctx.stroke(); };
|
||||
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = 'rgba(20,16,12,0.6)'; ctx.lineWidth = Math.max(4, zp * 1.1); arrow();
|
||||
ctx.strokeStyle = 'rgba(255,224,160,0.98)'; ctx.lineWidth = Math.max(2, zp * 0.55); arrow();
|
||||
}
|
||||
if (GAME.drag && cells.length > 1) {
|
||||
const label = `${cells.length} × ${costText(pieceCostNow(P), 1)}`;
|
||||
ctx.font = `600 12px ui-monospace, monospace`; ctx.fillStyle = 'rgba(14,12,11,0.85)';
|
||||
const tw = ctx.measureText(label).width;
|
||||
ctx.fillRect(p.x + zp * 6, p.y - zp * 6 - 14, tw + 10, 18); ctx.fillStyle = '#e7dccb'; ctx.fillText(label, p.x + zp * 6 + 5, p.y - zp * 6);
|
||||
}
|
||||
} else {
|
||||
const bw = P.kind === 'mold' ? MOLDS[P.mold].bw : P.bw, bh = P.kind === 'mold' ? MOLDS[P.mold].bh : P.bh;
|
||||
const { bx, by } = stampOrigin(bw, bh);
|
||||
const err = P.kind === 'mold' ? canPlaceMold(P.mold, bx, by) : canPlaceMachine(P.id, bx, by);
|
||||
const swap = !!err && GAME.ctrl && /already/.test(err) && !footprintBlocker(bx, by, bw, bh);
|
||||
const ok = (!err || swap) && inReach(bx * BS + bw * BS / 2, by * BS + bh * BS / 2) && canAfford(pieceCostNow(P), 1);
|
||||
const [x, y, w, h] = blockRectPx(bx, by, bw, bh);
|
||||
if (swap && ok) { ctx.fillStyle = 'rgba(255,190,90,0.35)'; ctx.fillRect(x, y, w, h); }
|
||||
if (P.kind === 'mold') {
|
||||
const md = MOLDS[P.mold], wm = WALL_OF[GAME.wallMat];
|
||||
for (let r = 0; r < md.h; r++) for (let c = 0; c < md.w; c++) {
|
||||
const ch = md.rows[r][c]; if (ch === '.') continue;
|
||||
ctx.fillStyle = ch === '#' ? (ok ? `rgba(${COLR[wm]},${COLG[wm]},${COLB[wm]},0.6)` : 'rgba(239,106,85,0.4)') : 'rgba(255,190,90,0.35)';
|
||||
ctx.fillRect(x + c * zp, y + r * zp, zp, zp);
|
||||
}
|
||||
} else {
|
||||
ctx.fillStyle = ok ? 'rgba(92,186,166,0.2)' : 'rgba(239,106,85,0.2)';
|
||||
if (P.id === 'wheel') { ctx.beginPath(); ctx.arc(x + w / 2, y + h / 2, w / 2, 0, Math.PI * 2); ctx.fill(); }
|
||||
else ctx.fillRect(x, y, w, h);
|
||||
if (P.id === 'engine') { ctx.fillStyle = 'rgba(255,210,122,0.6)'; ctx.fillRect(x + 4 * zp, y + 7 * zp, 4 * zp, zp); }
|
||||
if (P.id === 'fan') { const d = GAME.pieceDir.fan || 0, dx = [1, 0, -1, 0][d], dy = [0, 1, 0, -1][d]; ctx.strokeStyle = 'rgba(160,220,255,0.6)'; ctx.lineWidth = Math.max(1, zp * 0.4); ctx.beginPath(); ctx.moveTo(x + w / 2, y + h / 2); ctx.lineTo(x + w / 2 + dx * 28 * zp, y + h / 2 + dy * 28 * zp); ctx.stroke(); }
|
||||
}
|
||||
ctx.strokeStyle = ok ? 'rgba(92,186,166,0.95)' : 'rgba(239,106,85,0.95)'; ctx.lineWidth = Math.max(1, zp * 0.3);
|
||||
ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
|
||||
}
|
||||
}
|
||||
// ghost machines and molds (always visible)
|
||||
ctx.setLineDash([zp, zp]); ctx.lineWidth = Math.max(1, zp * 0.3);
|
||||
for (const g of ghostMachines) { const P = PIECE_BY_ID[g.type]; const [x, y, w, h] = blockRectPx(g.bx, g.by, P.bw, P.bh); ctx.strokeStyle = 'rgba(90,190,230,0.7)'; ctx.strokeRect(x, y, w, h); }
|
||||
for (const g of ghostMolds) { const md = MOLDS[g.kind]; const [x, y, w, h] = blockRectPx(g.bx, g.by, md.bw, md.bh); ctx.strokeStyle = 'rgba(255,190,90,0.7)'; ctx.strokeRect(x, y, w, h); }
|
||||
ctx.setLineDash([]);
|
||||
if (GAME.usingPad) {
|
||||
ctx.strokeStyle = 'rgba(255,230,200,0.9)'; ctx.lineWidth = 2;
|
||||
const cx = p.x + zp / 2, cy = p.y + zp / 2;
|
||||
ctx.beginPath(); ctx.moveTo(cx - 10, cy); ctx.lineTo(cx - 4, cy); ctx.moveTo(cx + 4, cy); ctx.lineTo(cx + 10, cy); ctx.moveTo(cx, cy - 10); ctx.lineTo(cx, cy - 4); ctx.moveTo(cx, cy + 4); ctx.lineTo(cx, cy + 10); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
if (t === 'build' && GAME.ctrl && !sel && !pasting) drawTag('Replace', '255,190,90', p.x + BS * zp * 1.2, p.y + BS * zp * 1.4);
|
||||
drawCursorLabel(p.x, p.y, t === 'vacuum' ? GAME.brush[GAME.tool] : t === 'ignite' || t === 'drill' ? 1 : 2);
|
||||
}
|
||||
// a small labelled tag drawn on the world (screen pixels)
|
||||
function drawTag(text, col, x, y, sub) {
|
||||
const ctx = R.ctx, u = Math.max(1, R.canvas.width / window.innerWidth);
|
||||
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace';
|
||||
ctx.save();
|
||||
ctx.font = `700 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.textBaseline = 'middle';
|
||||
const tw = ctx.measureText(text).width;
|
||||
ctx.font = `500 ${Math.round(11 * u)}px ${R.monoFont}`;
|
||||
const sw = sub ? ctx.measureText(sub).width + 8 * u : 0;
|
||||
const h = Math.round(19 * u), pad = 7 * u, w = tw + sw + pad * 2;
|
||||
x = Math.max(2, Math.min(R.canvas.width - w - 2, x)); y = Math.max(2, Math.min(R.canvas.height - h - 2, y));
|
||||
ctx.fillStyle = 'rgba(14,12,11,0.9)'; ctx.fillRect(x, y, w, h);
|
||||
ctx.fillStyle = `rgb(${col})`; ctx.fillRect(x, y, 3 * u, h);
|
||||
ctx.strokeStyle = `rgba(${col},0.8)`; ctx.lineWidth = u; ctx.strokeRect(x + u / 2, y + u / 2, w - u, h - u);
|
||||
ctx.font = `700 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.fillStyle = `rgb(${col})`; ctx.fillText(text, x + pad, y + h / 2 + u * 0.5);
|
||||
if (sub) { ctx.font = `500 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.fillStyle = 'rgba(231,220,203,0.9)'; ctx.fillText(sub, x + pad + tw + 8 * u, y + h / 2 + u * 0.5); }
|
||||
ctx.restore();
|
||||
}
|
||||
// Copy (teal) and cut (red, hatched): the box being dragged, what it would pick up, and a tag
|
||||
const SEL_COL = { copy: '92,186,166', cut: '239,106,85' };
|
||||
function drawSelection(mode, ax, ay) {
|
||||
const ctx = R.ctx, zp = R.zp, col = SEL_COL[mode], sd = GAME.selDrag;
|
||||
const sx = sd ? sd.x : ax, sy = sd ? sd.y : ay;
|
||||
const a = blockOf(Math.min(sx, ax), Math.min(sy, ay)), c = blockOf(Math.max(sx, ax), Math.max(sy, ay));
|
||||
const bw = c.bx - a.bx + 1, bh = c.by - a.by + 1;
|
||||
drawGrid(blockOf(ax, ay).bx, blockOf(ax, ay).by, 7);
|
||||
ctx.save();
|
||||
// what is inside: built blocks, and machines and molds that fit wholly inside
|
||||
let n = 0;
|
||||
if (sd) {
|
||||
ctx.fillStyle = `rgba(${col},${mode === 'cut' ? 0.42 : 0.3})`;
|
||||
for (let by = a.by; by <= c.by; by++) for (let bx = a.bx; bx <= c.bx; bx++) {
|
||||
if (bx < 0 || by < 0 || bx >= BW || by >= BH) continue;
|
||||
const b = by * BW + bx;
|
||||
if (blockP[b] && blockIntegrity(b) >= 8) { const [x, y, w, h] = blockRectPx(bx, by, 1, 1); ctx.fillRect(x, y, w, h); n++; }
|
||||
}
|
||||
ctx.strokeStyle = `rgba(${col},0.95)`; ctx.lineWidth = Math.max(1, zp * 0.35);
|
||||
for (const mm of machines) {
|
||||
if (!mm || PIECE_BY_ID[mm.type].fixed) continue;
|
||||
const P = PIECE_BY_ID[mm.type], mbx = mm.x / BS, mby = mm.y / BS;
|
||||
if (mbx >= a.bx && mby >= a.by && mbx + P.bw - 1 <= c.bx && mby + P.bh - 1 <= c.by) { const [x, y, w, h] = blockRectPx(mbx, mby, P.bw, P.bh); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); n++; }
|
||||
}
|
||||
for (const mo of molds) {
|
||||
const md = MOLDS[mo.kind], mbx = mo.x / BS, mby = mo.y / BS;
|
||||
if (mbx >= a.bx && mby >= a.by && mbx + md.bw - 1 <= c.bx && mby + md.bh - 1 <= c.by) { const [x, y, w, h] = blockRectPx(mbx, mby, md.bw, md.bh); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); n++; }
|
||||
}
|
||||
}
|
||||
const [x, y, w, h] = blockRectPx(a.bx, a.by, bw, bh);
|
||||
ctx.fillStyle = `rgba(${col},0.08)`; ctx.fillRect(x, y, w, h);
|
||||
if (mode === 'cut') {
|
||||
// red hatching: this will be taken down
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();
|
||||
ctx.strokeStyle = `rgba(${col},0.35)`; ctx.lineWidth = Math.max(1, zp * 0.25);
|
||||
const step = Math.max(8, zp * 2.5);
|
||||
ctx.beginPath(); for (let k = -h; k < w; k += step) { ctx.moveTo(x + k, y + h); ctx.lineTo(x + k + h, y); } ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.strokeStyle = `rgba(${col},0.98)`; ctx.lineWidth = Math.max(1.5, zp * (mode === 'cut' ? 0.45 : 0.35));
|
||||
ctx.setLineDash([zp * 1.2, zp * 0.8]); ctx.lineDashOffset = -(R.frame * 0.4) % (zp * 2);
|
||||
ctx.strokeRect(x, y, w, h); ctx.setLineDash([]); ctx.lineDashOffset = 0;
|
||||
ctx.restore();
|
||||
const label = mode === 'cut' ? '✂ CUT' : '⧉ COPY';
|
||||
const sub = !sd ? 'drag a box' : n ? (mode === 'cut' ? `${plural(n, 'piece')} · let go to take down` : `${plural(n, 'piece')} · let go to copy`) : 'nothing built in here';
|
||||
drawTag(label, col, x, y - Math.round(23 * Math.max(1, R.canvas.width / window.innerWidth)), sub);
|
||||
}
|
||||
// Pasting: the clipboard follows the cursor, snapped to the grid
|
||||
function drawPastePreview() {
|
||||
const ctx = R.ctx, zp = R.zp, bp = GAME.clip, { bx: ox, by: oy } = stampOrigin(bp.bw, bp.bh);
|
||||
drawGrid(aimBlock().bx, aimBlock().by, 7);
|
||||
ctx.save();
|
||||
const [x, y, w, h] = blockRectPx(ox, oy, bp.bw, bp.bh);
|
||||
ctx.strokeStyle = 'rgba(92,186,166,0.85)'; ctx.setLineDash([zp, zp]); ctx.lineWidth = Math.max(1, zp * 0.3);
|
||||
ctx.strokeRect(x, y, w, h); ctx.setLineDash([]);
|
||||
for (const [dx, dy, code, v] of bp.blocks) { const [bx2, by2, bw2, bh2] = blockRectPx(ox + dx, oy + dy, 1, 1); ctx.fillStyle = `rgba(${previewColor(code, v)},0.5)`; ctx.fillRect(bx2, by2, bw2, bh2); }
|
||||
for (const [type, dx, dy] of bp.machines) { const P = PIECE_BY_ID[type]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, P.bw, P.bh); ctx.strokeStyle = 'rgba(255,210,122,0.8)'; ctx.strokeRect(mx, my, mw, mh); }
|
||||
for (const [k, dx, dy] of bp.molds) { const md = MOLDS[k]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, md.bw, md.bh); ctx.strokeStyle = 'rgba(255,190,90,0.8)'; ctx.strokeRect(mx, my, mw, mh); }
|
||||
ctx.restore();
|
||||
drawTag('PASTE', '92,186,166', x, y - Math.round(23 * Math.max(1, R.canvas.width / window.innerWidth)), 'click to place · R flips · Esc stops');
|
||||
}
|
||||
Reference in New Issue
Block a user