Silo contents at a glance (t38); drill aimed past its reach stays calm (t41)

Silos show a wide fill window in the material's colour in both looks,
and the cursor label names the contents anywhere over the outline.
Aiming the drill beyond its reach keeps normal colours, marks where the
pulse stops, and drops the red circle and the out-of-reach toast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 18:40:05 -04:00
co-authored by Claude Opus 5.5
parent 9bc5a6c3af
commit b74a9173eb
12 changed files with 197 additions and 44 deletions
+29 -15
View File
@@ -119,7 +119,7 @@ function drawProcBlocks() {
// glass that shows how full it is, a roof with the loading hatch. All in grain units from its corner.
function drawSilo(ctx, mm, sx, sy, zp) {
if (!R.smoothOn) { drawSiloPixel(ctx, mm, sx, sy, zp); return; }
const X = u => sx + u * zp, Y = u => sy + u * zp, full = Math.min(1, (mm.sn || 0) / SILO_CAP);
const X = u => sx + u * zp, Y = u => sy + u * zp;
const steel = (x0, x1) => { const g = ctx.createLinearGradient(X(x0), 0, X(x1), 0); g.addColorStop(0, '#3b4249'); g.addColorStop(0.22, '#a9b3bb'); g.addColorStop(0.5, '#87919a'); g.addColorStop(1, '#353c43'); return g; };
ctx.save();
// legs, with short struts to the cone
@@ -146,22 +146,16 @@ function drawSilo(ctx, mm, sx, sy, zp) {
ctx.beginPath(); ctx.moveTo(X(1.3), Y(2)); ctx.lineTo(X(1.3), Y(19.8)); ctx.moveTo(X(2.3), Y(2)); ctx.lineTo(X(2.3), Y(19.8));
for (let yy = 2.6; yy < 19.8; yy += 1.1) { ctx.moveTo(X(1.3), Y(yy)); ctx.lineTo(X(2.3), Y(yy)); }
ctx.stroke();
// the sight glass: fills from the bottom in the colour of what is inside
const gx = 9.1, gy0 = 3, gy1 = 18.8, gh = gy1 - gy0;
ctx.fillStyle = '#14181c'; ctx.fillRect(X(gx), Y(gy0), zp * 1.1, zp * gh);
if (mm.sn > 0) { ctx.fillStyle = `rgb(${COLR[mm.sm]},${COLG[mm.sm]},${COLB[mm.sm]})`; ctx.fillRect(X(gx + 0.15), Y(gy1 - gh * full), zp * 0.8, zp * gh * full); }
ctx.strokeStyle = '#c9d1d8'; ctx.lineWidth = Math.max(1, zp * 0.1);
ctx.strokeRect(X(gx), Y(gy0), zp * 1.1, zp * gh);
for (let k = 1; k < 4; k++) { const yy = Y(gy1 - gh * k / 4); ctx.beginPath(); ctx.moveTo(X(gx - 0.35), yy); ctx.lineTo(X(gx), yy); ctx.stroke(); }
drawSiloWindow(ctx, mm, sx, sy, zp, false);
// what it holds, stencilled on the drum
if (zp >= 3) {
renderMonoFont();
const fs = Math.max(8, Math.round(zp * 1.15));
ctx.font = `700 ${fs}px ${R.monoFont}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = 'rgba(20,24,28,0.75)';
ctx.fillText('SILO', X(5.6), Y(5));
ctx.fillText('SILO', X(4.5), Y(5), 5 * zp);
ctx.font = `600 ${Math.max(8, Math.round(zp * 0.95))}px ${R.monoFont}`;
ctx.fillText(mm.sn ? mm.sn.toLocaleString('en-US') : 'EMPTY', X(5.6), Y(16.8));
ctx.fillText(mm.sn ? mm.sn.toLocaleString('en-US') : 'EMPTY', X(4.5), Y(16.8), 5 * zp);
if (mm.sn) { ctx.fillStyle = `rgb(${COLR[mm.sm]},${COLG[mm.sm]},${COLB[mm.sm]})`; ctx.fillRect(X(4.4), Y(17.9), zp * 2.4, zp * 0.55); }
}
// roof with a rim, and the loading hatch (its lid stands open)
@@ -173,7 +167,7 @@ function drawSilo(ctx, mm, sx, sy, zp) {
ctx.restore();
}
function drawSiloPixel(ctx, mm, sx, sy, zp) {
const full = Math.min(1, (mm.sn || 0) / SILO_CAP), col = `rgb(${COLR[mm.sm]},${COLG[mm.sm]},${COLB[mm.sm]})`;
const col = `rgb(${COLR[mm.sm]},${COLG[mm.sm]},${COLB[mm.sm]})`;
const grain = (x, y, c) => { ctx.fillStyle = c; ctx.fillRect(sx + x * zp, sy + y * zp, zp, zp); };
// Steel shaded by column (light on the left, dark at the right edge), darker rib rows, and fixed
// grain noise so it never shimmers. The drum is rows 2-19, the cone 20-22 narrowing to the gate.
@@ -191,8 +185,7 @@ function drawSiloPixel(ctx, mm, sx, sy, zp) {
grain(1, 21, '#2f353b'); grain(10, 21, '#2f353b');
for (let x = 4; x < 8; x++) grain(x, 23, '#1c1f23');
if (mm.open) for (let x = 8; x < 11; x++) grain(x, 23, '#c99a3c'); else for (let x = 3; x < 9; x++) grain(x, 23, '#6b737b');
// the sight glass fills from the bottom in the colour of what is inside
for (let y = 3; y < 19; y++) grain(9, y, mm.sn > 0 && y >= 19 - Math.max(1, Math.round(16 * full)) ? col : '#14181c');
drawSiloWindow(ctx, mm, sx, sy, zp, true);
// roof: a rim, shoulders, the dark hatch and its lid standing open
for (let x = 0; x < 12; x++) {
grain(x, 1, x >= 3 && x < 9 ? '#15181b' : '#5b646c');
@@ -204,14 +197,35 @@ function drawSiloPixel(ctx, mm, sx, sy, zp) {
renderMonoFont();
ctx.save(); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'rgba(20,24,28,0.75)';
ctx.font = `700 ${Math.max(8, Math.round(zp * 1.15))}px ${R.monoFont}`;
ctx.fillText('SILO', sx + 5.6 * zp, sy + 5 * zp);
ctx.fillText('SILO', sx + 4.5 * zp, sy + 5 * zp, 5 * zp);
ctx.font = `600 ${Math.max(8, Math.round(zp * 0.95))}px ${R.monoFont}`;
ctx.fillText(mm.sn ? mm.sn.toLocaleString('en-US') : 'EMPTY', sx + 5.6 * zp, sy + 16.8 * zp);
ctx.fillText(mm.sn ? mm.sn.toLocaleString('en-US') : 'EMPTY', sx + 4.5 * zp, sy + 16.8 * zp, 5 * zp);
if (mm.sn) { grain(4, 18, col); grain(5, 18, col); grain(6, 18, col); }
ctx.restore();
}
}
// The same framed, three-grain window in both looks; one grain remains visible near empty.
function drawSiloWindow(ctx, mm, sx, sy, zp, pixel) {
const height = mm.sn > 0 ? Math.max(1, Math.round(16 * Math.min(1, mm.sn / SILO_CAP))) : 0;
ctx.fillStyle = '#c9d1d8'; ctx.fillRect(sx + 7 * zp, sy + 2 * zp, 5 * zp, 18 * zp);
ctx.fillStyle = '#14181c'; ctx.fillRect(sx + 8 * zp, sy + 3 * zp, 3 * zp, 16 * zp);
if (!height) return;
// Lift dark fuel slightly so coal stays distinct from the empty glass.
const lift = Math.max(COLR[mm.sm], COLG[mm.sm], COLB[mm.sm]) < 80 ? 32 : 0;
if (pixel) {
for (let y = 19 - height; y < 19; y++) for (let x = 8; x < 11; x++) {
const h = Math.imul(mm.x + x, 374761393) ^ Math.imul(mm.y + y, 668265263);
const n = lift + (((h ^ h >>> 13) >>> 0) % 3 - 1) * 8;
ctx.fillStyle = `rgb(${COLR[mm.sm] + n},${COLG[mm.sm] + n},${COLB[mm.sm] + n})`;
ctx.fillRect(sx + x * zp, sy + y * zp, zp, zp);
}
} else {
ctx.fillStyle = `rgb(${COLR[mm.sm] + lift},${COLG[mm.sm] + lift},${COLB[mm.sm] + lift})`;
ctx.fillRect(sx + 8 * zp, sy + (19 - height) * zp, 3 * zp, height * zp);
}
}
// Shelves show each block's fill and materials; the bottom gate belongs to the column.
function drawRackBlocks() {
const ctx = R.ctx;
+1 -2
View File
@@ -43,7 +43,7 @@ function toolPulse(ax, ay, width, tune) {
// every cell in the cone is hit, with no untouched stripes between the rays
drillPulses.push({ ox: o.x, oy: o.y, ang, w: drillWidth, jit: rnd(), n: Wd.rays, damp: Wd.damp, en: new Float32Array(Wd.rays).fill(Wd.en), s: new Float32Array(Wd.rays), r: 0, range, tune: tune || 0 });
lastPulseTick = tick;
// say why nothing breaks: the first solid on the centre line is too hard, or out of reach
// say why nothing breaks: the first solid on the centre line is too hard
let status = '';
for (let k = 3; k < reach; k++) {
const x = Math.floor(o.x + dx / d * k), y = Math.floor(o.y + dy / d * k);
@@ -57,7 +57,6 @@ function toolPulse(ax, ay, width, tune) {
}
break;
}
if (!status && d > reach) status = `Out of reach; a ${Wd.name} pulse reaches ${reach} grains.`;
return { fired: true, status };
}
const THIN_BONUS = 0.75, CRUMBLE_MAX = 16;
+17 -11
View File
@@ -81,17 +81,23 @@ function hoverInfo(ax, ay) {
if (ax < 0 || ay < 0 || ax >= W || ay >= H) return { t1: '', t2: ['Outside the world'], warn: '', air: true };
const i = ay * W + ax, m = mat[i];
let warn = '';
let t1 = MD[m].name, t2 = [];
const p = part[i], id = mach[i];
let t1 = MD[m].name, t2 = [], contents = '';
const p = part[i];
// Cone gaps have no machine cells; hover still belongs to the silo's whole outline.
const silo = machines.find(mm => mm && mm.type === 'silo' && ax >= mm.x && ax < mm.x + mm.w && ay >= mm.y && ay < mm.y + mm.h);
const id = silo ? silo.id : mach[i];
const b = blockP[((ay / BS) | 0) * BW + ((ax / BS) | 0)];
const bar = occ[i] ? ents.find(e => ax >= e.ix && ax < e.ix + BAR_W && ay >= e.iy && ay < e.iy + BAR_H) : null;
if (bar) { t1 = MD[bar.m].name; t2.push(fmtT(bar.T)); }
if (bar && !silo) { t1 = MD[bar.m].name; t2.push(fmtT(bar.T)); }
else if (id && machines[id]) {
const mm = machines[id], P = PIECE_BY_ID[mm.type]; t1 = P.name;
if (mm.type === 'dock') { t2.push('sells what drops through its grate', `${orders.length} open ${orders.length === 1 ? 'order' : 'orders'}`); }
if (mm.type === 'wheel' || mm.type === 'engine') t2.push(`making ${mm.out.toFixed(1)} power`);
if (mm.type === 'fan') { t2.push(mm.on ? 'on' : 'off · press F'); if (mm.hot > 760) { t2.push(`${mm.hot}°C · melts at 1085°C`); warn = 'too hot'; } }
if (mm.type === 'silo') t2.push(mm.sn ? `holds ${mm.sn.toLocaleString('en-US')} / ${SILO_CAP.toLocaleString('en-US')} ${MD[mm.sm].name.toLowerCase()}` : `empty (holds ${SILO_CAP.toLocaleString('en-US')} grains of one material)`, mm.open ? 'gate open · F shuts it' : 'gate shut · F opens it');
if (mm.type === 'silo') {
contents = mm.sn ? `${MD[mm.sm].name.toLowerCase()} ${mm.sn.toLocaleString('en-US')} / ${SILO_CAP.toLocaleString('en-US')}` : 'empty';
t2.push(mm.sn ? `holds ${mm.sn.toLocaleString('en-US')} / ${SILO_CAP.toLocaleString('en-US')} ${MD[mm.sm].name.toLowerCase()}` : `empty (holds ${SILO_CAP.toLocaleString('en-US')} grains of one material)`, mm.open ? 'gate open · F shuts it' : 'gate shut · F opens it');
}
} else if (b === BP_RACK) {
const top = rackTop((ay >> 2) * BW + (ax >> 2)), counts = new Map();
let n = 0, capacity = 0;
@@ -135,9 +141,9 @@ function hoverInfo(ax, ay) {
if (!ok && toolId() === 'drill') warn = HARD[m] > 6 ? 'cannot be drilled' : 'too hard';
}
if (!bar && PH[m] === PH_POWDER) t2.push('loose');
if (!bar && m === EMPTY && bgm[i]) { t1 = bgm[i] === WOOD ? 'Tree trunk' : 'Leaves'; t2.unshift(bgm[i] === WOOD ? 'drill it for wood chunks' : 'water and sand pass through'); }
if (!id && !bar && m === EMPTY && bgm[i]) { t1 = bgm[i] === WOOD ? 'Tree trunk' : 'Leaves'; t2.unshift(bgm[i] === WOOD ? 'drill it for wood chunks' : 'water and sand pass through'); }
if (flags[i] & F_BURN) t2.push('burning');
const mo = moldAt(ax, ay);
const mo = silo ? null : moldAt(ax, ay);
if (mo) {
t1 = MOLDS[mo.kind].name;
t2 = [mo.state === 'ready' ? (mo.kind === 'ingot' ? `${mo.ready.length} ${mo.ready.length === 1 ? 'bar' : 'bars'} set` : '100% set · press F') : mo.state === 'empty' ? 'empty' : `${mo.state === 'short' ? 'not full' : mo.state} ${Math.round(mo.fill * 100)}%`];
@@ -146,12 +152,12 @@ function hoverInfo(ax, ay) {
const hint = furnaceJunkHint(mo).trim(); if (hint) t2.push(hint);
if (player.lens) t2.push(fmtT(temp[i]));
}
const reachOk = toolId() === 'drill' ? inDrillReach(ax, ay, GAME.drillW) : inReach(ax, ay);
const reachOk = toolId() === 'drill' || inReach(ax, ay);
if (!reachOk) { t2.push('out of reach'); warn = 'out of reach'; }
// what it does, unless the cursor is on a machine or mold, which have their own details
const what = bar ? bar.m : !mo && !id && !(p && p !== P_GRATE && p !== P_PIPE) && m !== BELTL && m !== BELTR && m !== PIPE ? (m === EMPTY && bgm[i] ? bgm[i] : m) : 0;
const t3 = what ? reactionsOf(what) : [];
return { t1, t2, t3, warn, air: m === EMPTY && !bgm[i] && !bar && !mo && !id && !p && !b };
return { t1, t2, t3, warn, contents, air: m === EMPTY && !bgm[i] && !bar && !mo && !id && !p && !b };
}
function renderInfo() {
const r = hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
@@ -167,8 +173,8 @@ function drawCursorLabel(px, py, r) {
const ctx = R.ctx, dpr = R.canvas.width / window.innerWidth;
const fs = Math.round(12 * dpr * SET.uiScale);
if (!R.uiFont) R.uiFont = getComputedStyle(document.documentElement).getPropertyValue('--font-ui').trim() || 'sans-serif';
const main = info.t1 + (player.lens && R.lens ? '' : '');
const sub = info.warn || (R.lens ? info.t2.find(t => /°C$/.test(t)) || '' : '');
const main = info.t1 + (info.contents && info.warn ? ` ${info.contents}` : '');
const sub = info.warn || info.contents || (R.lens ? info.t2.find(t => /°C$/.test(t)) || '' : '');
ctx.save();
ctx.font = `600 ${fs}px ${R.uiFont}`;
const w1 = ctx.measureText(main).width;
@@ -182,7 +188,7 @@ function drawCursorLabel(px, py, r) {
ctx.beginPath(); ctx.roundRect(x, y, w1 + w2 + padX * 2, h, 4 * dpr); ctx.fill();
ctx.textBaseline = 'middle';
ctx.font = `600 ${fs}px ${R.uiFont}`; ctx.fillStyle = '#efe6d8'; ctx.fillText(main, x + padX, y + h / 2);
if (sub) { ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`; ctx.fillStyle = info.warn ? '#ef6a55' : '#ffd27a'; ctx.fillText(sub, x + padX + w1 + fs * 0.6, y + h / 2); }
if (sub) { ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`; ctx.fillStyle = info.warn ? '#ef6a55' : info.contents ? '#efe6d8' : '#ffd27a'; ctx.fillText(sub, x + padX + w1 + fs * 0.6, y + h / 2); }
ctx.restore();
}
+5 -2
View File
@@ -74,7 +74,7 @@ function drawDrillAim(ax, ay) {
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 col = pulseRGB(GAME.drillTune);
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
@@ -84,6 +84,9 @@ function drawDrillAim(ax, ay) {
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;
// A solid far edge marks where the pulse stops, even with a distant cursor.
ctx.beginPath(); far.forEach((pt, k) => k ? ctx.lineTo(pt.x, pt.y) : ctx.moveTo(pt.x, pt.y));
ctx.strokeStyle = `rgba(${col},0.85)`; ctx.lineWidth = Math.max(1, zp * 0.22); ctx.stroke();
// 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) {
@@ -95,7 +98,7 @@ function drawDrillAim(ax, ay) {
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);
ctx.strokeStyle = 'rgba(239,230,216,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();
-3
View File
@@ -70,9 +70,6 @@ function drawPreviews() {
if (!reachOk && t !== 'drill' && !sel) {
drawReachCircle(REACH, 0.35);
}
if (t === 'drill' && !sel && !pasting && !inDrillReach(ax, ay, GAME.drillW)) {
drawReachCircle(drillReach(GAME.drillW), 0.45);
}
drawDrillPulses();
if (t === 'drill' && !sel && !pasting) drawDrillAim(ax, ay);
if (sel) drawSelection(sel, ax, ay);