Files
deepgrain-foundry/src/ui/info.js
T
44r0n7andClaude Opus 5.5 80420c3453 Refactor: merge duplicated rules, split the largest files by topic (t21)
- One helper each for wall and mold refunds, tube edges and lanes, pipe text, piece dimensions,
  font lookup, reach circles and surface averaging.
- previews, smooth, notebook, materials, worldgen and selection split into topic files
  (preview-drill, preview-selection, smooth-shaders, notebook-market, notebook-blueprints,
  material-tables, worldgen-helpers, selection-pickers, selection-panels).
- Every suite's output identical apart from timings; seeds make the same worlds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:35:12 -04:00

189 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Discovery cards, hover info (I) and the cursor label.
// ---------- Discovery cards ----------
// Short cards at the bottom centre when you find or make something new. They fade
// on their own (hover holds them); everything they say is also in the notebook.
function showCard(c) {
if (!SET.cardSeconds) return;
const box = $('cards');
while (box.children.length >= 3) box.firstChild.remove();
const card = el('div', { class: 'card-pop plate interactive' + (c.accent ? ' ' + c.accent : ''), role: 'status' });
const timed = SET.cardSeconds > 0;
card.innerHTML = `<div class="top">${c.swatch ? `<span class="sw" style="background:${rgbOf(c.swatch)}"></span>` : ''}<span class="label">${esc(c.kind)}</span><button class="x" aria-label="Dismiss">×</button></div>`
+ `<div class="title">${esc(c.title)}</div>${(c.body || []).filter(Boolean).map((t, k) => `<div class="${k ? 'p2' : 'p1'}">${esc(t)}</div>`).join('')}`
+ (timed ? `<i class="life" style="--dur:${(SET.cardSeconds * (c.hold || 1)).toFixed(1)}s"></i>` : '');
const close = () => { if (card.classList.contains('out')) return; card.classList.add('out'); setTimeout(() => card.remove(), 380); };
card.querySelector('.x').onclick = () => { playSfx('ui'); close(); };
if (timed) card.querySelector('.life').addEventListener('animationend', close);
box.appendChild(card);
}
function howText(e) {
const F = e.from ? MD[e.from].name : '', f = F.toLowerCase();
switch (e.how) {
case 'dig': return e.from === WOOD ? 'Cut from a tree.' : `Broken loose from ${f}.`;
case 'melt': return `${F} melted at ${fmtT(e.T)}.`;
case 'smelt': return e.m === SLAG || e.m === MSLAG ? `Left over when ${f} smelted.` : `${F} smelted at ${fmtT(e.T)}.`;
case 'freeze': return e.from === WATER ? (e.m === SNOW ? 'Water froze into flakes in the cold air.' : 'Water froze in the cold air.') : `${F} set as it cooled below ${fmtT(e.T)}.`;
case 'boil': return `${F} boiled at ${fmtT(e.T)}.`;
case 'condense': return `${F} cooled below ${fmtT(e.T)} and condensed.`;
case 'ignite': return `${F} caught fire at ${fmtT(e.T)}.`;
case 'burn': return `What ${f} leaves behind when it burns.`;
case 'fire': return `${F} fired into brick in the heat above ${fmtT(e.T)}.`;
case 'blow': return 'Air blown through molten iron burned off its extra carbon.';
case 'alloy': return `${F} melted into molten copper and the two mixed.`;
case 'char': return `${F} got hot with no air to burn in, and charred.`;
case 'refine': return `Ground out of ${f}.`;
case 'press': return `Pressed from ${f} by a Brick press.`;
case 'cut': return `Cut from ${f} by a saw.`;
default: return '';
}
}
function discoveryCard(e) {
const m = e.m;
if (m === FIRE) { showCard({ kind: 'Discovery', title: 'Fire', body: [howText(e), 'Fuel burns where it touches air. More air, more heat.'] }); return; }
showCard({ kind: 'New material', title: MD[m].name, swatch: m, body: [howText(e), MD[m].desc] });
}
// Everything a material does when heated, cooled, wetted or lit, in short phrases. The hover
// box and the notebook's Materials page both show this list.
function reactionsOf(m, forNotebook) {
const d = MD[m], out = [];
if (!d || m === EMPTY || d.ph === PH_ITEM && !d.melt) return out;
const nm = k => MD[k].name.toLowerCase();
if (d.ign) out.push(`burns, catching at ${fmtT(d.ign)}`);
if (m === WOOD || m === LWOOD) out.push('turns to charcoal if heated with no air');
if (m === CLAY || m === LCLAY) out.push('fires into brick above 880°C');
if (m === WATER) out.push('freezes below 0°C', 'boils at 100°C');
else if (d.boil) out.push(`boils at ${fmtT(d.boil)}`);
if (d.melt) {
if (d.smelt) out.push(`smelts at ${fmtT(d.melt)} into ${nm(d.meltTo)} and slag`);
else if (d.meltTo === WATER) out.push('melts above 0°C');
else if (d.ph === PH_ITEM) out.push(`melts at ${fmtT(d.melt)}`);
else out.push(`melts at ${fmtT(d.melt)}${d.meltTo === MAGMA ? ' into magma' : d.meltTo === MGL && m !== GLASS ? ' into glass' : d.meltTo === MSLAG && m !== SLAG ? ' into slag' : ''}`);
}
if (d.freeze) out.push(`sets into ${nm(d.freezeTo)} below ${fmtT(d.freeze)}`);
if (m === STEAM) out.push('turns back to water below 97°C under a roof', 'rises into the clouds in the open');
else if (d.condT) out.push(`turns back to ${nm(d.condTo)} below ${fmtT(d.condT)}`);
if (HOTLIQ[m]) out.push(m === MAGMA ? 'boils water, and crusts into basalt in it' : 'boils water it touches');
if (ALLOY[m]) out.push(`mixes into molten copper as ${nm(FRZTO[ALLOY[m].into])}`);
if (m === MFE) out.push('turns to steel with air blown through it');
if (m === SNOW || m === ICE) out.push('chills water, and freezes it in cold air');
if (POROUS[m] && !d.ign) out.push('lets air through to a fire');
if (d.smelt && d.ph === PH_POWDER) out.push('smothers a fire under it');
if (m === ACORN) out.push('sprouts on dirt or grass under open sky');
if ((d.ph === PH_POWDER || d.ph === PH_ITEM) && m !== SNOW) out.push(DENS[m] < 1 ? 'floats on water' : 'sinks in water');
if (m === SNOW) out.push('floats on water');
if (forNotebook && d.ph === PH_SOLID && d.yld && d.yld !== m && MD[d.yld]) out.push(`drills into ${nm(d.yld)}`);
return out;
}
// What is under the cursor: t1 is the name, t2 the details, warn a short problem
// (too hard, out of reach) for the label next to the cursor.
function hoverInfo(ax, ay) {
if (SET.fog && fogSeen(ax, ay) < 64) return { t1: 'Unexplored', t2: [], t3: [] };
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];
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)); }
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');
} else if (b === BP_RACK) {
const top = rackTop((ay >> 2) * BW + (ax >> 2)), counts = new Map();
let n = 0, capacity = 0;
for (let q = top; q < NB && blockP[q] === BP_RACK; q += BW) {
const s = rackState.get(q); capacity += RACK_CAP; n += s.n;
for (const [m, count] of s.q) counts.set(m, (counts.get(m) || 0) + count);
}
t1 = 'Bar rack'; t2.push(`${n} / ${capacity} bars`);
t2.push(...[...counts].slice(0, 4).map(([m, count]) => `${count} ${MD[m].name.toLowerCase()}`));
if (counts.size > 4) t2.push('+ more');
t2.push(rackState.get(top).open ? 'open: letting bars out' : 'closed: F lets bars out');
if (n === capacity) t2.push('full');
} else if (isProcCode(b)) {
const s = procState.get((ay >> 2) * BW + (ax >> 2)), saw = b === BP_SAW, press = b === BP_PRESS;
t1 = press ? 'Brick press' : saw ? 'Saw' : 'Grinder';
t2.push(s && s.blocked ? saw || press ? 'stopped: no room underneath; a belt there carries them away' : 'stopped: clear the space under it' : s && tick - s.busyT < 30 ? press ? 'pressing' : saw ? 'cutting' : 'grinding' : press ? 'idle: drop clay lumps on top' : saw ? 'idle: drop stone rubble or wood chunks on top' : 'idle: drop rubble, gravel, sand or dirt on top', press ? `${stats.pressed || 0} green bricks pressed` : saw ? `${(stats.bricks || 0).toLocaleString('en-US')} stone blocks, ${(stats.planks || 0).toLocaleString('en-US')} planks cut` : `${(stats.refined || 0).toLocaleString('en-US')} ground, ${(stats.refinedOre || 0).toLocaleString('en-US')} ore found`);
} else if (p === P_FILTER) {
const fb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pr = filterParts(filterInfo(fb)), ar = blockV[fb] & 128 ? '→' : '←';
t1 = 'Filter ' + ar; t2.push(`drops ${pr.drops}`, `carries ${pr.rides} ${ar}`, 'F to change · I shows all');
} else if (p === P_PIPE) {
const pb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pn = pipeNetOf(pb);
t1 = 'Pipe' + (m !== EMPTY ? ` · ${MD[m].name} flowing past` : '');
t2.push(pipeContentsText(pn));
} else if (p) t1 = ({ 1: 'Screw tube ↑', 2: 'Screw tube ↓', 3: 'Screw tube ←', 4: 'Screw tube →', 5: 'Grate', 6: 'Gate (open)', 7: 'Water wheel', 8: 'Engine intake' })[p] + (m !== EMPTY ? ` · ${MD[m].name}` : '');
else if (m === BELTL || m === BELTR) t1 = 'Belt ' + (m === BELTL ? '←' : '→');
else if (m === PIPE && isPipeCode(b)) {
const pb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pn = pipeNetOf(pb), v = blockV[pb], arrows = ['↑', '→', '↓', '←'];
t1 = b === BP_PUMP ? `Pump ${arrows[v & 3]}` : b === BP_OUTLET ? `Outlet ${['↓', '←', '↑', '→'][v & 3]}` : 'Pipe';
t2.push(pipeContentsText(pn));
if (b === BP_PUMP) t2.push(v & 4 ? 'off · press F' : 'on · F turns it off');
if (b === BP_OUTLET) t2.push(v & 4 ? 'closed · press F' : 'open · F closes it');
}
else if (b === BP_WALL && PH[m] === PH_SOLID && !(flags[i] & F_NAT)) t1 = `${MD[m].name} block`;
const n = nets[netId[i]];
if (n && isPow[i] && !POWER_FREE) t2.push(`network ${n.supply.toFixed(1)} supply / ${n.demand.toFixed(1)} demand`);
if (player.lens && !bar) t2.push(fmtT(temp[i]));
if (!bar && m !== EMPTY && PH[m] === PH_SOLID && !PARTMAT[m] && (flags[i] & F_NAT)) {
const ok = HARD[m] <= BIT_TIERS[player.bitTier].hard;
t2.push(HARD[m] > 6 ? 'cannot be drilled' : ok ? `drillable (hardness ${HARD[m]})` : `too hard (${HARD[m]})`);
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 (flags[i] & F_BURN) t2.push('burning');
const mo = 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)}%`];
if (mo.blocked) t2.push('blocked: leave one open block under it');
if (mo.flaw) t2.push(mo.flaw);
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);
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 };
}
function renderInfo() {
const r = hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
const box = $('info');
const h = `${r.t1 ? `<span class="t1">${esc(r.t1)}</span>` : ''}${r.t2.length ? `<span class="t2">${esc(r.t2.join(' · '))}</span>` : ''}${r.t3 && r.t3.length ? `<span class="t3">${esc(r.t3.join(' · '))}</span>` : ''}`;
if (box.innerHTML !== h) box.innerHTML = h;
}
// Name of what is under the cursor, drawn right next to it
function drawCursorLabel(px, py, r) {
if (!SET.cursorLabel || (!GAME.mouse.in && !GAME.usingPad)) return;
const info = hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
if (!info.t1 || info.air) return;
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)) || '' : '');
ctx.save();
ctx.font = `600 ${fs}px ${R.uiFont}`;
const w1 = ctx.measureText(main).width;
ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`;
const w2 = sub ? ctx.measureText(sub).width + fs * 0.6 : 0;
const padX = fs * 0.5, h = fs * 1.6;
let x = px + (r + 1.6) * R.zp + 6 * dpr, y = py + (r + 1.2) * R.zp + 4 * dpr;
if (x + w1 + w2 + padX * 2 > R.canvas.width) x = px - (r + 1.6) * R.zp - 6 * dpr - (w1 + w2 + padX * 2);
if (y + h > R.canvas.height) y = py - (r + 1.2) * R.zp - h;
ctx.fillStyle = 'rgba(20,17,15,0.82)';
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); }
ctx.restore();
}
function pipeContentsText(pn) { return pn && pn.n ? `line holds ${pn.n.toLocaleString('en-US')} / ${pn.cap.toLocaleString('en-US')} ${MD[pn.m].name.toLowerCase()}` : `line empty (room for ${pn ? pn.cap : PIPE_CAP})`; }