Resonance ping: right click with the drill lights up nearby veins through fog (t50)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 16:47:35 -04:00
co-authored by Claude Opus 5.5
parent ef4ba6d24a
commit 569ec6e5b6
9 changed files with 103 additions and 11 deletions
+10 -9
View File
@@ -320,10 +320,10 @@ Tool helpers: reach, brushes, and plain digging.
- functions: setBuildRange, inBuildRange, inReach, forBrush, drillReach, inDrillReach, toolDig - functions: setBuildRange, inBuildRange, inReach, forBrush, drillReach, inDrillReach, toolDig
- variables: BUILD_MAX, buildBox - variables: BUILD_MAX, buildBox
### src/sim/drill.js (161 lines) ### src/sim/drill.js (177 lines)
The resonance drill: tuned pulses that shatter one material. The resonance drill: tuned pulses that shatter one material.
- functions: drillOrigin, pulseRay, builtCell, toolPulse, crumbleAround, crumbly, shatterCell, pulseTick - functions: drillOrigin, pulseRay, builtCell, toolPulse, crumbleAround, crumbly, shatterCell, pulseTick, pingRadius, resonancePing
- variables: PULSE_W, PULSE_EVERY, PULSE_SPEED, PULSE_K, drillPulses, lastPulseTick, drillWidth, THIN_BONUS, CRUMBLE_MAX, THIN_BONUS_OFF - variables: PULSE_W, PULSE_EVERY, PULSE_SPEED, PULSE_K, drillPulses, lastPulseTick, drillWidth, THIN_BONUS, CRUMBLE_MAX, THIN_BONUS_OFF, PING_VEINS
### src/sim/nature.js (168 lines) ### src/sim/nature.js (168 lines)
Trees (background layer), acorns, saplings, and waking freezing water. Trees (background layer), acorns, saplings, and waking freezing water.
@@ -412,9 +412,10 @@ Scale colour without changing the smooth-look class.
Machine overlays: fans, engines, silo and the rest, drawn on top of the world. Machine overlays: fans, engines, silo and the rest, drawn on top of the world.
- functions: cellRect, drawHazardRims, drawMachines, drawProcBlocks, drawSilo, drawSiloPixel, drawSiloWindow, drawRackBlocks - functions: cellRect, drawHazardRims, drawMachines, drawProcBlocks, drawSilo, drawSiloPixel, drawSiloWindow, drawRackBlocks
### src/render/overlays.js (285 lines) ### src/render/overlays.js (321 lines)
Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles. Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles.
- functions: spawnFloater, drawRain, drawFlowArrows, drawJoinMark, drawJoinMarks, flowLoaded, drawFlowRates, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles - functions: spawnFloater, drawRain, drawFlowArrows, drawJoinMark, drawJoinMarks, flowLoaded, drawFlowRates, pingImage, drawPing, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles
- variables: PING_SPEED, PING_FADE
### src/render/smooth.js (99 lines) ### src/render/smooth.js (99 lines)
Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution. Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution.
@@ -512,10 +513,10 @@ Keyboard and mouse listeners.
- functions: isTyping - functions: isTyping
- variables: canvas - variables: canvas
### src/ui/actions.js (243 lines) ### src/ui/actions.js (254 lines)
What input does: overlays, F, pick, copy / cut / paste, undo, building and tool use each frame. Which overlays are on, shown as a coloured frame round the screen and a named badge What input does: overlays, F, pick, copy / cut / paste, undo, building and tool use each frame. Which overlays are on, shown as a coloured frame round the screen and a named badge
- functions: updateOverlayUI, toggleLens, doInteract, pickUnderCursor, aimBlock, stampOrigin, selModeHeld, startPaste, stopSelectAndPaste, plural, piecesIn, doUndo, endRmbRecord, dragDirFor, blockLabel, primaryPress, primaryRelease, secondaryPress, toolTick - functions: updateOverlayUI, toggleLens, doInteract, pickUnderCursor, aimBlock, stampOrigin, selModeHeld, startPaste, stopSelectAndPaste, plural, piecesIn, doUndo, endRmbRecord, dragDirFor, blockLabel, primaryPress, primaryRelease, secondaryPress, firePing, toolTick
- variables: ovShown - variables: ovShown, PING_RECHARGE
### src/ui/gamepad.js (60 lines) ### src/ui/gamepad.js (60 lines)
Gamepad input. Gamepad input.
@@ -545,7 +546,7 @@ Shared world colours, minimap and full map.
- functions: resetMap, mapArea, updateMap, drawMap, mapFit, drawFullMap, openMap, zoomMap, zoomMinimap - functions: resetMap, mapArea, updateMap, drawMap, mapFit, drawFullMap, openMap, zoomMap, zoomMinimap
- variables: MAP_S, MAP - variables: MAP_S, MAP
### src/ui/loop.js (137 lines) ### src/ui/loop.js (138 lines)
The frame loop and boot. The frame loop and boot.
- functions: scanVisible, frame, boot - functions: scanVisible, frame, boot
- variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT - variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT
+10
View File
@@ -4,6 +4,16 @@ What agents have done, newest first. Add an entry for every piece of work: the d
note or request it was for, what changed, the files touched, the tests run, and anything left note or request it was for, what changed, the files touched, the tests run, and anything left
open or not verified. Keep each entry short; the git history has the details. open or not verified. Keep each entry short; the git history has the details.
## 2026-10-27: t50 resonance ping (Claude Code)
- Right click with the drill: `firePing` (`src/ui/actions.js`) calls `resonancePing` (`src/sim/drill.js`),
which lists natural vein cells (coal seam and the six ore veins) within 3x the bit's reach.
`drawPing` (`src/render/overlays.js`) draws an echo ring and the veins inside it over fog, from
a one-time offscreen image (one clipped drawImage per frame), fading over 4.5 s. 3 s recharge.
- Files: `src/sim/drill.js`, `src/ui/actions.js`, `src/render/overlays.js`, `src/ui/loop.js`,
`src/ui/notebook.js`, `src/ui/selection-panels.js`, `test/t_drill.js`, `docs/PROJECT_MAP.md`.
- Tests: 4 checks in `test/t_drill.js`; `python3 test/run_all.py --browser` all passed.
## 2026-10-27: t64 build range about the screen; out-of-range replace ghosts (Claude Code) ## 2026-10-27: t64 build range about the screen; out-of-range replace ghosts (Claude Code)
- `setBuildRange`/`inBuildRange` in `src/sim/tools.js`: the UI sets the view box each frame - `setBuildRange`/`inBuildRange` in `src/sim/tools.js`: the UI sets the view box each frame
+36
View File
@@ -109,6 +109,42 @@ function drawFlowRates() {
} }
ctx.restore(); ctx.restore();
} }
// The resonance ping's echo: a ring spreading from the bot, the veins it found lighting up inside
// it, then fading once it has gone out. The veins are drawn once into a small image, so each
// frame is one clipped drawImage however many there are. Drawn over fog, so unexplored rock shows.
const PING_SPEED = 160, PING_FADE = 4500;
function pingImage(p) {
const size = p.r * 2 + 1, c = document.createElement('canvas'); c.width = c.height = size;
const g = c.getContext('2d'), img = g.createImageData(size, size), d = img.data, ox = Math.floor(p.x - p.r), oy = Math.floor(p.y - p.r);
for (const i of p.cells) {
const x = i % W - ox, y = ((i / W) | 0) - oy;
if (x < 0 || y < 0 || x >= size || y >= size) continue;
const k = (y * size + x) * 4, m = mat[i];
d[k] = Math.min(255, COLR[m] + 90); d[k + 1] = Math.min(255, COLG[m] + 90); d[k + 2] = Math.min(255, COLB[m] + 90); d[k + 3] = 220;
}
g.putImageData(img, 0, 0);
return { c, ox, oy };
}
function drawPing() {
const p = GAME.ping;
if (!p) return;
const ms = performance.now() - p.t0, front = ms / 1000 * PING_SPEED, done = p.r / PING_SPEED * 1000;
if (ms > done + PING_FADE) { GAME.ping = null; return; }
if (!p.img) p.img = pingImage(p);
const ctx = R.ctx, zp = R.zp, c = worldToScreenPx(p.x, p.y), o = worldToScreenPx(p.img.ox, p.img.oy);
ctx.save();
ctx.beginPath(); ctx.arc(c.x, c.y, Math.min(front, p.r + 1) * zp, 0, Math.PI * 2); ctx.clip();
ctx.globalAlpha = ms < done ? 1 : Math.max(0, 1 - (ms - done) / PING_FADE);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(p.img.c, o.x, o.y, p.img.c.width * zp, p.img.c.height * zp);
ctx.restore();
if (front < p.r) {
ctx.save();
ctx.strokeStyle = `rgba(160,225,255,${0.85 * (1 - front / p.r)})`; ctx.lineWidth = Math.max(2, zp * 0.8);
ctx.beginPath(); ctx.arc(c.x, c.y, front * zp, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
}
}
// Outline a run of blocks: strong while it is being edited, faint when pointed at // Outline a run of blocks: strong while it is being edited, faint when pointed at
function drawRunOutline(run, strong) { function drawRunOutline(run, strong) {
if (!run || !run.length) return; if (!run || !run.length) return;
+16
View File
@@ -159,3 +159,19 @@ function pulseTick() {
} }
drillPulses = drillPulses.filter(p => p.r < p.range + 3); drillPulses = drillPulses.filter(p => p.r < p.range + 3);
} }
// Resonance ping (t50): an echo from the bot that finds ore veins in the rock around it, under
// fog too. Reach grows with the bit. Only reads the world; the UI draws the echo.
const PING_VEINS = new Uint8Array(NMAT); for (const m of [COALV, CUV, FEV, SNV, ZNV, AGV, AUV]) PING_VEINS[m] = 1;
function pingRadius() { return BIT_TIERS[player.bitTier].reach * 3; }
// Natural vein cells within r of (cx, cy), and the materials found.
function resonancePing(cx, cy, r = pingRadius()) {
const cells = [], found = new Set(), r2 = r * r;
const x0 = Math.max(0, Math.floor(cx - r)), x1 = Math.min(W - 1, Math.ceil(cx + r));
const y0 = Math.max(0, Math.floor(cy - r)), y1 = Math.min(H - 1, Math.ceil(cy + r));
for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) {
const dx = x - cx, dy = y - cy, i = y * W + x, m = mat[i];
if (dx * dx + dy * dy > r2 || !PING_VEINS[m] || !(flags[i] & F_NAT)) continue;
cells.push(i); found.add(m);
}
return { cells, found: [...found], r, x: cx, y: cy };
}
+11
View File
@@ -197,9 +197,20 @@ function secondaryPress(shift) {
if (GAME.selDrag) { GAME.selDrag = null; hudDirty = true; GAME.rmbConsumed = true; } if (GAME.selDrag) { GAME.selDrag = null; hudDirty = true; GAME.rmbConsumed = true; }
else if (GAME.paste) { GAME.paste = false; hudDirty = true; playSfx('ui'); GAME.rmbConsumed = true; } else if (GAME.paste) { GAME.paste = false; hudDirty = true; playSfx('ui'); GAME.rmbConsumed = true; }
else if (t === 'build' && GAME.drag) { GAME.drag = null; GAME.dragDir = -1; GAME.rmbConsumed = true; } else if (t === 'build' && GAME.drag) { GAME.drag = null; GAME.dragDir = -1; GAME.rmbConsumed = true; }
else if (t === 'drill') { firePing(); GAME.rmbConsumed = true; }
else { GAME.rmbConsumed = false; if (t === 'build') { endRmbRecord(); GAME.rmbRec = { label: '', ops: [] }; } } else { GAME.rmbConsumed = false; if (t === 'build') { endRmbRecord(); GAME.rmbRec = { label: '', ops: [] }; } }
} }
// Right click with the drill: a resonance ping. The echo is drawn by drawPing (overlays.js).
const PING_RECHARGE = 180;
function firePing() {
if (GAME.ping && tick >= GAME.ping.tick && tick - GAME.ping.tick < PING_RECHARGE) { setStatus('The ping is still ringing; try again in a moment.'); playSfx('error'); return; }
const p = resonancePing(bot.x + bot.w / 2, bot.y + bot.h / 2);
GAME.ping = Object.assign(p, { tick, t0: performance.now() });
playSfx('pulse', 2);
const names = p.found.map(m => MD[m].name.toLowerCase());
setStatus(names.length ? `Ping: ${names.slice(0, 3).join(', ')}${names.length > 3 ? ' and more' : ''} nearby.` : `Ping: no veins within ${Math.round(p.r / BS)} blocks.`, 'info');
}
// continuous actions, once per simulation tick // continuous actions, once per simulation tick
function toolTick() { function toolTick() {
const t = toolId(), ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y); const t = toolId(), ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
+1
View File
@@ -73,6 +73,7 @@ function frame(now) {
if (GAME.editRun) drawRunOutline(GAME.editRun, true); else if (hb >= 0) drawRunOutline(filterRun(hb), false); if (GAME.editRun) drawRunOutline(GAME.editRun, true); else if (hb >= 0) drawRunOutline(filterRun(hb), false);
if (GAME.infoOverlay) { drawFlowArrows(); drawFlowRates(); } if (GAME.infoOverlay) { drawFlowArrows(); drawFlowRates(); }
drawJoinMarks(); drawJoinMarks();
drawPing();
drawFilterTags(!!GAME.infoOverlay, GAME.editRun ? GAME.editRun[0] : hb); drawFilterTags(!!GAME.infoOverlay, GAME.editRun ? GAME.editRun[0] : hb);
drawFloaters(); drawFloaters();
drawRain(); drawRain();
+1 -1
View File
@@ -57,7 +57,7 @@ function renderNotebook() {
} }
function controlsHtml() { function controlsHtml() {
const kb = [ const kb = [
['Dev console (marks the world)', 'Backtick (`)'], ['Fly', 'W A S D'], ['Boost while flying', 'Shift'], ['Tool modifier (while not flying)', 'Shift'], ['Use tool', 'Left mouse'], ['Second action', 'Right mouse'], ['Dev console (marks the world)', 'Backtick (`)'], ['Fly', 'W A S D'], ['Boost while flying', 'Shift'], ['Tool modifier (while not flying)', 'Shift'], ['Use tool', 'Left mouse'], ['Second action', 'Right mouse'], ['Resonance ping: light up nearby veins (Drill)', 'Right mouse'],
['Tools', '1 – 4'], ['Select one to blow · piece', 'Q / E'], ['Switch build tab', 'Shift + Q / E'], ['Variant: material · direction · flip', 'R'], ['Wall: cycle shape · flip diagonal drag', 'T'], ['Wall or mold material menu (Build)', 'F'], ['Tools', '1 – 4'], ['Select one to blow · piece', 'Q / E'], ['Switch build tab', 'Shift + Q / E'], ['Variant: material · direction · flip', 'R'], ['Wall: cycle shape · flip diagonal drag', 'T'], ['Wall or mold material menu (Build)', 'F'],
['Pick up selection or what you click (Pneumatic nozzle)', 'Left drag · Shift: everything'], ['Select (Pneumatic nozzle)', 'Click a bag name · Q/E'], ['Add to selection (Pneumatic nozzle)', 'Ctrl+click a bag name'], ['Show everything found (Pneumatic nozzle)', 'Hold Ctrl in the bag'], ['Clear selection (Pneumatic nozzle)', 'Clear in the bag'], ['Blow everything loose in the bag', 'Shift + right click'], ['Nozzle or brush size', '[ ] or Shift + wheel'], ['Pick up selection or what you click (Pneumatic nozzle)', 'Left drag · Shift: everything'], ['Select (Pneumatic nozzle)', 'Click a bag name · Q/E'], ['Add to selection (Pneumatic nozzle)', 'Ctrl+click a bag name'], ['Show everything found (Pneumatic nozzle)', 'Hold Ctrl in the bag'], ['Clear selection (Pneumatic nozzle)', 'Clear in the bag'], ['Blow everything loose in the bag', 'Shift + right click'], ['Nozzle or brush size', '[ ] or Shift + wheel'],
['Replace what is built (Build)', 'Hold G'], ['Copy a box of what you built', 'Hold C, drag'], ['Cut a box (refunded, paste it back)', 'Hold X, drag'], ['Paste again', 'V'], ['Undo building', 'Ctrl + Z'], ['Blueprint library', 'B'], ['Zoom', 'Mouse wheel'], ['Interact (gates, molds, fans, filters)', 'F'], ['Thermal lens', 'H'], ['Info overlay: filters, belt and screw conveyor directions, grains per second', 'I'], ['Bot lamp', 'L'], ['Replace what is built (Build)', 'Hold G'], ['Copy a box of what you built', 'Hold C, drag'], ['Cut a box (refunded, paste it back)', 'Hold X, drag'], ['Paste again', 'V'], ['Undo building', 'Ctrl + Z'], ['Blueprint library', 'B'], ['Zoom', 'Mouse wheel'], ['Interact (gates, molds, fans, filters)', 'F'], ['Thermal lens', 'H'], ['Info overlay: filters, belt and screw conveyor directions, grains per second', 'I'], ['Bot lamp', 'L'],
+1 -1
View File
@@ -20,7 +20,7 @@ function renderSelCard() {
const pw = PULSE_W[GAME.drillW], tn = GAME.drillTune; const pw = PULSE_W[GAME.drillW], tn = GAME.drillTune;
h = `<div class="row cycle"><kbd>Q</kbd><span class="name grow">Resonance drill</span><span class="chip hot">${pw.name} pulse</span><kbd>E</kbd></div> h = `<div class="row cycle"><kbd>Q</kbd><span class="name grow">Resonance drill</span><span class="chip hot">${pw.name} pulse</span><kbd>E</kbd></div>
<div class="row" style="flex-wrap:wrap;gap:4px"><span class="chip">${esc(b.name.toLowerCase())}</span><span class="chip">hardness ≤ ${b.hard}</span><span class="chip">reach ${drillReach(GAME.drillW)}</span>${tn ? `<span class="chip ok"><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(tn)}"></span>tuned: ${esc(MD[tn].name.toLowerCase())} · R</span>` : '<span class="chip">untuned · R tunes</span>'}</div> <div class="row" style="flex-wrap:wrap;gap:4px"><span class="chip">${esc(b.name.toLowerCase())}</span><span class="chip">hardness ≤ ${b.hard}</span><span class="chip">reach ${drillReach(GAME.drillW)}</span>${tn ? `<span class="chip ok"><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(tn)}"></span>tuned: ${esc(MD[tn].name.toLowerCase())} · R</span>` : '<span class="chip">untuned · R tunes</span>'}</div>
<div class="sub">Hold to send pulses at the cursor: rock they shake hard enough shatters and falls. <kbd>Q</kbd>/<kbd>E</kbd> width: narrow bores a long tunnel fast, wide clears a big face up close. <kbd>R</kbd> over a material tunes to it, so only that shatters. Built blocks are never harmed.</div>`; <div class="sub">Hold to send pulses at the cursor: rock they shake hard enough shatters and falls. <kbd>Q</kbd>/<kbd>E</kbd> width: narrow bores a long tunnel fast, wide clears a big face up close. <kbd>R</kbd> over a material tunes to it, so only that shatters. Right click pings: veins nearby light up, even unexplored. Built blocks are never harmed.</div>`;
} else if (t === 'build') { } else if (t === 'build') {
// the build tool in general; the piece itself is described in the menu's hover popup // the build tool in general; the piece itself is described in the menu's hover popup
const P = piece(); const P = piece();
+17
View File
@@ -171,4 +171,21 @@ check('a mold and what sets inside it stay safe from the drill', run(`(() => {
for (const i of moldCellsAt(mo.kind, mo.x, mo.y).walls) if (crumbly(i)) return false; for (const i of moldCellsAt(mo.kind, mo.x, mo.y).walls) if (crumbly(i)) return false;
return true; return true;
})()`)); })()`));
// Resonance ping (t50): finds natural veins within reach of the bit, under fog too, and
// nothing else.
flatWorld(run, 240, 160, 150);
const ping = run(`(() => {
player.bitTier = 0; const cx = 100, cy = 100, r = pingRadius();
setCell(cy * W + cx + 10, CUV, 20, F_NAT); setCell(cy * W + cx - 20, FEV, 20, F_NAT);
setCell(cy * W + cx + r + 5, AUV, 20, F_NAT); // out of reach
setCell((cy + 5) * W + cx, CUV, 20, 0); // not natural (set loose)
setCell((cy - 5) * W + cx, STONE, 20, F_NAT); // not a vein
const p = resonancePing(cx, cy);
player.bitTier = BIT_TIERS.length - 1; const far = resonancePing(cx, cy);
return { n: p.cells.length, found: p.found.map(m => MD[m].name), r, farFound: far.found.map(m => MD[m].name), farR: far.r };
})()`);
check('a ping finds natural veins in reach', ping.n === 2 && ping.found.includes('Copper ore vein') && ping.found.includes('Iron ore vein'), JSON.stringify(ping));
check('it skips rock, loose ore and veins out of reach', !ping.found.includes('Gold ore vein') && ping.n === 2, JSON.stringify(ping));
check('a better bit pings farther', ping.farR > ping.r && ping.farFound.includes('Gold ore vein'), JSON.stringify(ping));
check('a ping never changes the world', run('(() => { const h0 = mat.reduce((a, m, i) => (a * 31 + m + i) | 0, 0); resonancePing(100, 100); return h0 === mat.reduce((a, m, i) => (a * 31 + m + i) | 0, 0); })()'));
done(); done();