Drill: visible dust; pulses push loose material back and open a channel (t92)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 18:32:31 -04:00
co-authored by Claude Opus 5.5
parent 729378770a
commit dadf62a28d
8 changed files with 151 additions and 22 deletions
+6
View File
@@ -542,6 +542,12 @@ above, including:
flow, pockets drain), with a little dust. All damping is counted per grain travelled
(attenuation is per distance in real physics), retuned so rock bores feel the same as before.
- **Pulses stream loose grains back toward the bot (t92 follow-up, 2026-10-28).** Owner
playtesting found that surface bouncing did not open a channel. Keep shaking the travelled
ray path while the pulse is in flight, bias short hops back toward the bot, and keep sideways
and upward escapes when blocked. Grains fall normally after each hop. Dust must show as a few
soft puffs where grains move; the previous rubble-coloured debris squares were not visible.
## Ideas not built yet
- **Concrete**, as a stronger wall material from rubble.
+2 -2
View File
@@ -327,7 +327,7 @@ Tool helpers: reach, brushes, and plain digging.
- functions: setBuildRange, inBuildRange, inReach, forBrush, drillReach, inDrillReach, toolDig
- variables: BUILD_MAX, buildBox
### src/sim/drill.js (187 lines)
### src/sim/drill.js (202 lines)
The resonance drill: tuned pulses that shatter one material.
- 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, PING_VEINS
@@ -482,7 +482,7 @@ Scale colour without changing the smooth-look class.
Machine overlays: fans, engines, silo and the rest, drawn on top of the world.
- functions: cellRect, drawHazardRims, drawMachines, drawProcBlocks, drawSilo, drawSiloPixel, drawSiloWindow, drawRackBlocks
### src/render/overlays.js (370 lines)
### src/render/overlays.js (384 lines)
Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles.
- functions: spawnFloater, drawRain, drawFlowArrows, drawJoinMark, drawJoinMarks, flowLoaded, drawFlowRates, pingImage, drawPing, drawHoveredVein, drawPingRecharge, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles
- variables: PING_SPEED, PING_HOLD, PING_FADE, PING_BANDS, PING_GLINTS, GLINT_MS
+40
View File
@@ -4,6 +4,46 @@ 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
open or not verified. Keep each entry short; the git history has the details.
## 2026-10-28: t92 follow-up, visible dust and a botward channel through rubble (Codex)
- Traced shake events through `drainEvents` to `spawnDebris` and `drawParticles`. Events were
handled above the world, but rendered only two opaque, rubble-coloured 0.7-grain squares.
Added a dust flag to the existing debris event/particle pipeline: one pale radial puff,
gently rising/expanding and fading over 36 frames. Retained particles setting and particle
limit; at most four shake dust events per tick. Normal shatter debris/sparks unchanged.
- Kicks previously visited only the advancing 2.2-grain wave front, with random sideways/up
directions. Now each in-flight ray keeps shaking its travelled path, with local energy
attenuated through powder/liquid; stops at solid. Tries the reverse ray first, then lift and
side escapes. Retains seeded rnd(), one-grain move/enter hops, occupied/part/stamp guards,
sealed corners and three moves/ray/tick; gravity settles grains normally. No damping retune.
- Added steel-medium 35-grain-deep pile/rock regression and gravity-only control. At 480 ticks:
33/35 centre-line cells empty versus 9/35; rubble centre x 72 -> 56.168 versus 70.68;
all 875 rubble grains retained. The original pulse code gives only 10/35 empty cells and
centre x 68.341, failing channel, transport, dust and travelled-path checks. An isolated
advanced-front ray checks interior botward movement; existing movement/dust caps and guards,
attenuation, repeatability and heap-fluidising tests retained. New browser check exercises
event -> visible soft pixels, expiry and particles-off.
- Stone depth/opened cells against original t92 baseline: narrow 22/181 -> 24/195,
medium 13/186 -> 13/198, wide 6/134 -> 6/133; all within 10%, including depth.
Small 480-tick pile benchmark median, five measured samples after warmup: 24.18 -> 33.88 ms
(about 0.02 ms/tick extra). This is a small fixture, not a whole-game performance profile.
- Full `python3 test/run_all.py --browser` exited 0, ALL PASSED: 32 headless suites and
gameplay 361, smooth 28, console 18, caps 7, GPU 15, goldens 28, light 71, Firefox 2.
Final pile fixture was moved into the real steel reach and its conservation assertion added
after that run; the final `python3 test/run_all.py` passes all 32 suites, drill 66 checks.
Inspected final `shots/t92b-channel.png` and `shots/t92b-channel-smooth.png`: lowered
channel, basalt notch and soft dust over the streaming rubble. First recapture timed out
starting the world; host-GPU recaptures passed. Caps confirmed NVIDIA Blackwell; no claim
that the timeout was caused by unavailable hardware. Screenshot setup: `/tmp/t92b-channel.js`,
`tools/shot.py --setup /tmp/t92b-channel.js --zoom 8 --no-light --freeze` (add smooth look).
Evidence: `/tmp/t92b-red-final.log`, `/tmp/t92b-drill.log`, `/tmp/t92b-all-tests.log`,
`/tmp/t92b-headless-final.log`. No page errors on final captures.
- Files: `src/sim/drill.js`, `src/render/overlays.js`, `src/ui/events.js`, `test/t_drill.js`,
`test/t_gpu.py`, generated `docs/PROJECT_MAP.md`, owner decision in `docs/DESIGN.md`,
t92 tracking and this log. Both feedback items ticked; t92 moved to Review. No commit.
Owner playtesting and performance in a large active world remain unverified.
## 2026-10-28: t92 drill pulses pass through rubble and fluidise it (Codex)
- Converted every pulse loss to a per-grain factor, applied with `factor ** step` outside
+16 -2
View File
@@ -331,8 +331,13 @@ function drawBot() {
ctx.beginPath(); ctx.moveTo(cx - bot.face * zp, sy + zp * 0.6 + bob); ctx.lineTo(cx - bot.face * zp * 1.6, sy - zp * 0.6 + bob); ctx.stroke();
ctx.restore();
}
function spawnDebris(x, y, m) {
function spawnDebris(x, y, m, dust = false) {
if (!SET.particles || R.particles.length > 400) return;
if (dust) {
R.particles.push({ x: x + 0.5, y: y + 0.5, vx: (Math.random() - 0.5) * 0.12,
vy: -0.06 - Math.random() * 0.06, life: 36, r: 205, g: 190, b: 165, dust: true });
return;
}
for (let k = 0; k < 2; k++) R.particles.push({ x: x + Math.random(), y: y + Math.random(), vx: (Math.random() - 0.5) * 0.5, vy: -Math.random() * 0.5, life: 20 + Math.random() * 20, r: COLR[m], g: COLG[m], b: COLB[m] });
}
function spawnSpark(x, y) {
@@ -358,11 +363,20 @@ function drawParticles() {
p.x += dx / d * sp; p.y += dy / d * sp; p.life--;
if (p.life <= 0) { ps.splice(k, 1); continue; }
} else {
p.x += p.vx; p.y += p.vy; p.vy += 0.04; p.life--;
p.x += p.vx; p.y += p.vy; p.vy += p.dust ? 0 : 0.04; p.life--;
if (p.life <= 0) { ps.splice(k, 1); continue; }
}
if (SET.fog && fogSeen(p.x, p.y) < 64) continue;
const s = worldToScreenPx(p.x, p.y);
if (p.dust) {
const radius = zp * (1 + (36 - p.life) * 0.035);
const puff = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, radius);
puff.addColorStop(0, `rgba(${p.r},${p.g},${p.b},${0.32 * p.life / 36})`);
puff.addColorStop(1, `rgba(${p.r},${p.g},${p.b},0)`);
ctx.fillStyle = puff;
ctx.fillRect(s.x - radius, s.y - radius, radius * 2, radius * 2);
continue;
}
ctx.fillStyle = `rgba(${p.r | 0},${p.g | 0},${p.b | 0},${Math.min(1, p.life / 10)})`;
const sz = p.spark ? Math.max(1, zp * 0.5) : p.suck ? zp : Math.max(1, zp * 0.7);
ctx.fillRect(s.x, s.y, sz, sz);
+32 -17
View File
@@ -115,7 +115,38 @@ function pulseTick(step = 0.5) {
let e = p.en[k];
if (e < 0.04) continue;
const ray = pulseRay(p.ox, p.oy, p.ang, Wd, -1 + 2 * (k + p.jit) / p.n), ux = ray.ux, uy = ray.uy;
let sd = p.s[k], kicks = 0;
// Keep shaking the travelled path while the wave advances, including the pile interior.
let shake = Wd.en, kicks = 0;
for (let d = 0.5; d < lim && shake >= 0.04 && kicks < 3; d++) {
const x = Math.floor(ray.x + ux * d), y = Math.floor(ray.y + uy * d);
if (x < 0 || y < 0 || x >= W || y >= H) break;
if (x >= bx0 && x <= bx1 && y >= by0 && y <= by1) continue;
const i = y * W + x, m = mat[i], ph = PH[m];
if (ph === PH_SOLID) break;
if (ph === PH_LIQUID) { shake *= 0.970225; continue; }
if (ph !== PH_POWDER) continue;
shake *= 0.97;
if (stamp[i] !== tickStamp && !occ[i] && !part[i] && rnd() < 0.55 * shake) {
// Lead with the reverse ray; blocked grains can lift or spill to either side.
const backX = Math.abs(ux) > 0.38 ? -Math.sign(ux) : 0;
const backY = Math.abs(uy) > 0.38 ? -Math.sign(uy) : 0;
const side = rnd() < 0.5 ? -1 : 1;
for (let hop = 0; hop < 5; hop++) {
const mx = hop < 2 ? backX : hop < 4 ? side : -side;
const my = hop === 0 ? backY : hop === 1 ? -1 : -(hop & 1);
const nx = x + mx, ny = y + my;
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
const j = ny * W + nx;
if (mat[j] !== EMPTY && PH[mat[j]] !== PH_GAS) continue;
if (occ[j] || part[j] || stamp[j] === tickStamp || !enter(m, ph, j, mx, my)) continue;
if (my && CORNER_SEAL && PH[mat[i + mx]] === PH_SOLID && PH[mat[i + my * W]] === PH_SOLID) continue;
move(i, j, x, y, nx, ny); kicks++;
if (dust < 4) { emit({ t: 'debris', x, y, m, dust: true }); dust++; }
break;
}
}
}
let sd = p.s[k];
while (sd < lim && e >= 0.04) {
sd += step;
const x = Math.floor(ray.x + ux * sd), y = Math.floor(ray.y + uy * sd);
@@ -130,22 +161,6 @@ function pulseTick(step = 0.5) {
if (ph === PH_LIQUID) { e *= liquid; continue; }
if (ph === PH_POWDER) {
e *= loose;
// At most three one-cell hops per ray/tick; gravity settles the shaken grains.
if (kicks < 3 && stamp[i] !== tickStamp && !occ[i] && !part[i] && rnd() < 1.1 * e * step) {
const side = rnd() < 0.5 ? -1 : 1;
for (let hop = 0; hop < 4; hop++) {
const mx = hop < 2 ? side : -side, my = -(hop & 1);
const nx = x + mx, ny = y + my;
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
const j = ny * W + nx;
if (mat[j] !== EMPTY && PH[mat[j]] !== PH_GAS) continue;
if (occ[j] || part[j] || stamp[j] === tickStamp || !enter(m, ph, j, mx, my)) continue;
if (my && CORNER_SEAL && PH[mat[i + mx]] === PH_SOLID && PH[mat[i - W]] === PH_SOLID) continue;
move(i, j, x, y, nx, ny); kicks++;
if (dust < 4) { emit({ t: 'debris', x, y, m }); dust++; }
break;
}
}
continue;
}
if (builtCell(i, x, y)) { e *= built; continue; }
+1 -1
View File
@@ -17,7 +17,7 @@ function drainEvents() {
showCard({ kind: e.kind === 'bit' ? 'Drill bit upgraded' : 'Bag upgraded', title: e.kind === 'bit' ? BIT_TIERS[e.tier].name : TANK_TIERS[e.tier].name, accent: 'milestone',
body: [e.kind === 'bit' ? `Digs hardness ${BIT_TIERS[e.tier].hard} and below.` : `Holds ${TANK_TIERS[e.tier].cap.toLocaleString('en-US')} grains.`] });
hudDirty = true;
} else if (e.t === 'debris') spawnDebris(e.x, e.y, e.m);
} else if (e.t === 'debris') spawnDebris(e.x, e.y, e.m, e.dust);
else if (e.t === 'suck') spawnSuck(e.x, e.y, e.m);
else if (e.t === 'warn') spawnFloater(e.x, e.y, e.text, '#ff8a72');
else if (e.t === 'sold') spawnFloater(e.x, e.y, '+' + fmtMoney(e.v));
+41
View File
@@ -206,6 +206,35 @@ check('115 grains of rubble prevent basalt breaking during sustained drilling',
const through118 = rubbleShield(118);
check('118 grains extinguish the ray before it can even shake basalt', through118 === 0 && !run('digp.some(d => d > 0)'), `${through118} basalt opened`);
// A 35-grain-deep pile against basalt: compare with gravity alone, tracking only rubble.
function channelPile(fire) {
flatWorld(run, 240, 160, 110);
return run(`(() => {
RS=12345; tick=0; lastPulseTick=-99; drillPulses=[]; player.bitTier=3;
bot.x=45; bot.y=88; EV.length=0;
for(let y=85;y<110;y++) {
for(let x=55;x<90;x++) setCell(y*W+x,RSTONE,20,0);
for(let x=90;x<150;x++) setCell(y*W+x,BASALT,20,F_NAT);
}
const measure=()=>{let n=0,sx=0,empty=0;for(let i=0;i<N;i++) if(mat[i]===RSTONE){n++;sx+=i%W;}
for(let x=55;x<90;x++) if(mat[90*W+x]===EMPTY) empty++;
return {n,cx:sx/n,empty};};
const before=measure(); let dust=0;
for(let t=0;t<480;t++) {
${fire ? 'toolPulse(130,90,1,0);' : ''} simTick();
dust+=EV.filter(e=>e.t==='debris' && e.dust).length; EV.length=0;
}
return {before,after:measure(),dust};
})()`);
}
const channelRest=channelPile(false), channel=channelPile(true);
check('medium pulses open most of the centre ray through a 35-grain pile within 480 ticks',
channel.after.empty>=28 && channel.after.empty>channelRest.after.empty+15,JSON.stringify(channel));
check('the pile streams back toward the bot rather than just bouncing on its surface',
channel.after.cx<channel.before.cx-5 && channel.after.cx<channelRest.after.cx-5,JSON.stringify(channelRest));
check('channel drilling retains every rubble grain',channel.after.n===channel.before.n,`${channel.before.n}->${channel.after.n}`);
check('shaking loose grains emits dust puffs',channel.dust>0,`${channel.dust} puffs`);
// Absorption over a fixed distance, isolated from gravity, kicks and cell breakage.
function transmission(material, distance, step, tune = 0) {
flatWorld(run, 240, 160, 150);
@@ -246,6 +275,18 @@ check('pulses fluidise a settled rubble pile so its height drops',
check('shaking conserves the pile material',fluidPile.grains===fluidPile.left,`${fluidPile.grains}->${fluidPile.left}`);
check('repeating a seeded pile gives the same result',JSON.stringify(shakenPile(true))===JSON.stringify(fluidPile));
// A wave must still shake loose grains far behind its advancing front, throughout the path.
flatWorld(run,240,160,150);
const travelled=run(`(() => {
RS=12345; bot.x=0;bot.y=0;tickStamp=1;EV.length=0;
for(const x of [25,45,65]) setCell(80*W+x,RSTONE,20,0);
const p={ox:19.5,oy:80.5,ang:0,w:1,jit:0.5,n:1,damp:PULSE_W[1].damp,
en:new Float32Array([1.4]),s:new Float32Array([60]),r:60,range:100,tune:0};
drillPulses=[p];pulseTick();
return [25,45,65].filter(x=>mat[80*W+x-1]===RSTONE).length;
})()`);
check('the travelled ray path pushes interior grains one cell toward the bot',travelled>=2,`${travelled}/3 botward hops (probabilistic kicks)`);
// One artificially long ray crosses many exposed grains in a single tick, exercising the caps.
flatWorld(run,240,160,150);
const kicks = run(`(() => {
+13
View File
@@ -22,6 +22,19 @@ with launch(viewport={'width':960,'height':600}) as (_,pg,url):
}""")
assert startup['warm']>=0
print('PASS both looks submit behind loading screen; first visible frame ms',startup)
dust=pg.evaluate("""()=>{
const particles=SET.particles,fogOn=SET.fog;SET.particles=true;SET.fog=false;
R.particles=[];EV.length=0;emit({t:'debris',x:bot.x,y:bot.y,m:RSTONE,dust:true});drainEvents();
const spawned=R.particles.length===1 && R.particles[0].dust===true;
R.ctx.clearRect(0,0,R.canvas.width,R.canvas.height);drawParticles();
const data=R.ctx.getImageData(0,0,R.canvas.width,R.canvas.height).data;
let visible=0,soft=0;for(let i=3;i<data.length;i+=4)if(data[i]){visible++;if(data[i]<100)soft++;}
for(let n=0;n<36;n++)drawParticles();const expired=R.particles.length===0;
SET.particles=false;spawnDebris(bot.x,bot.y,RSTONE,true);const disabled=R.particles.length===0;
SET.particles=particles;SET.fog=fogOn;return {spawned,visible,soft,expired,disabled};
}""")
assert dust['spawned'] and dust['visible']>4 and dust['soft']==dust['visible'] and dust['expired'] and dust['disabled'],dust
print('PASS pulse dust events draw a visible soft puff, expire and respect particles setting',dust)
pg.add_script_tag(path=str(root/'tools/perf-scenes.js'))
pg.evaluate("async()=>{perfScene('factory',1024,640);await startThreads();simPool.force=true;SET.look='pixel';SET.zoom=2;resizeRender();snapCamera();drawWorld();}")
result=pg.evaluate('''async () => {