Game-drawn cursors for each tool; a real pointer when building (t89)

This commit is contained in:
2026-10-29 22:59:53 -04:00
parent 974e5a6272
commit 99aae0aae0
8 changed files with 176 additions and 3 deletions
+6
View File
@@ -650,3 +650,9 @@ above, including:
above 4294967295 with a nearby hint. Zero is a seed; empty means random on Generate. above 4294967295 with a nearby hint. Zero is a seed; empty means random on Generate.
The dice uses the same six-digit random rule as the initial field and refreshes the The dice uses the same six-digit random rule as the initial field and refreshes the
automatic camp name while preserving a name the player typed. automatic camp name while preserving a name the player typed.
- **Game cursors (t89, 2026-10-29).** Building has a small hollow arrow at the mouse,
retaining the highlighted block or drag line. The owner found a block alone odd to aim with.
Nozzle, igniter, copy/cut and paste use open crosshairs in their existing colours; the drill
keeps its light cross. Draw pointers in the game in both looks, leaving the aim point open.
Menus keep the system pointer.
+3 -2
View File
@@ -596,9 +596,9 @@ Gamepad input.
- functions: padPressed, pollPad - functions: padPressed, pollPad
- variables: PAD - variables: PAD
### src/ui/previews.js (241 lines) ### src/ui/previews.js (269 lines)
Cursor previews: shared grid helpers, air streaks and tool preview dispatch. Cursor previews: shared grid helpers, air streaks and tool preview dispatch.
- functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, previewBlock - functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, drawToolCursor, previewBlock
### src/ui/preview-drill.js (67 lines) ### src/ui/preview-drill.js (67 lines)
Drill pulse wavefronts and aiming fan. The pulses in flight: a wavefront running out from the face of the bot, fading as it goes Drill pulse wavefronts and aiming fan. The pulses in flight: a wavefront running out from the face of the bot, fading as it goes
@@ -651,6 +651,7 @@ The frame loop and boot.
- `test/t_conveyors.js`: Screw conveyors that never carry liquid (it flows through), screw and belt throughput, filters. - `test/t_conveyors.js`: Screw conveyors that never carry liquid (it flows through), screw and belt throughput, filters.
- `test/t_corner_seal.js`: Corner-touching square walls seal loose grains without changing free piles or worldgen. - `test/t_corner_seal.js`: Corner-touching square walls seal loose grains without changing free piles or worldgen.
- `test/t_cursor.js`: Ore identification, cached connected veins, ping timing, settings and label lifetime. - `test/t_cursor.js`: Ore identification, cached connected veins, ping timing, settings and label lifetime.
- `test/t_cursors.py`: t89: exact mouse pointers, open aim centres, menus and both-look cursor captures.
- `test/t_dev_console.js`: Dev command validation, discovery, build costs, runtime controls and save metadata. - `test/t_dev_console.js`: Dev command validation, discovery, build costs, runtime controls and save metadata.
- `test/t_dev_console.py`: Dev console confirmation, keyboard isolation, HUD, time controls and saved badge. - `test/t_dev_console.py`: Dev console confirmation, keyboard isolation, HUD, time controls and saved badge.
- `test/t_diag_screws.js`: Diagonal screws: sealed four-lane corner transfers, bars, joins and persistence. - `test/t_diag_screws.js`: Diagonal screws: sealed four-lane corner transfers, bars, joins and persistence.
+4
View File
@@ -94,6 +94,10 @@ screenshots go to `shots/t72-{retrace,escape,open}.png`.
## Browser tests ## Browser tests
- `python3 test/t_cursors.py` checks t89: open pointer centres at the exact mouse position,
both looks, building and selection drags, distant nozzle blowing, zoom 2/12, canvas scaling,
menus and gamepad suppression. Captures each tool/mode as `shots/t89-{pixel,smooth}-*.png`.
- `python3 test/t_seed.py` checks the new-world seed field: typed and pasted text, - `python3 test/t_seed.py` checks the new-world seed field: typed and pasted text,
real text dragging, clamping at 4294967295, zero and empty seeds, keyboard dice real text dragging, clamping at 4294967295, zero and empty seeds, keyboard dice
activation, automatic/custom names and real generation with seed zero. Both-look activation, automatic/custom names and real generation with seed zero. Both-look
+13
View File
@@ -4,6 +4,19 @@ 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-29: t89 custom game cursors (Codex)
- Finished interrupted work: hollow build arrow at the exact mouse, open coloured crosses for
nozzle/igniter/copy/cut/paste, existing drill cross, and native pointer on paused canvas.
- Files: src/ui/previews.js, src/styles/style.css, test/{t_cursors.py,run_all.py},
docs/{DESIGN,TESTING,PROJECT_MAP}.md, tasks/t89* and this log. Map regenerated.
- All 41 headless suites, 29 focused t_cursors.py checks and pixel test/play.py pass;
no page/console errors. Isolated pre-change build fails the cursor check as expected.
Browser tests needed host GPU access after sandbox world startup timed out.
- Inspected shots/t89-{pixel,smooth}-{drill,nozzle,build,igniter,copy,cut,paste}.png and
extra drag/blow/zoom/menu shots: open aim centres, clear arrow and retained previews.
No simulation/worldgen changes; full browser suite not run. Ready for owner review.
## 2026-10-29: t100 new-world seed controls (Codex) ## 2026-10-29: t100 new-world seed controls (Codex)
- Added a labeled dice button and shared seed parsing/random rule; ignore non-digits, - Added a labeled dice button and shared seed parsing/random rule; ignore non-digits,
+1
View File
@@ -30,6 +30,7 @@ body {
-webkit-user-select: none; user-select: none; touch-action: none; -webkit-user-select: none; user-select: none; touch-action: none;
} }
canvas#gpu, canvas#game { position: fixed; inset: 0; width: 100vw; height: 100vh; display: block; image-rendering: pixelated; cursor: none; } /* the game draws its own aim */ canvas#gpu, canvas#game { position: fixed; inset: 0; width: 100vw; height: 100vh; display: block; image-rendering: pixelated; cursor: none; } /* the game draws its own aim */
body.paused canvas#game { cursor: default; }
/* overlay modes (thermal lens, info overlay): a coloured frame with corner brackets, a light wash /* overlay modes (thermal lens, info overlay): a coloured frame with corner brackets, a light wash
for the info overlay so the world stays clear, and a named badge at the bottom of the screen */ for the info overlay so the world stays clear, and a named badge at the bottom of the screen */
#ovframe { position: fixed; inset: 0; pointer-events: none; opacity: 0; transition: opacity 0.2s; --ov: 92, 186, 166; } #ovframe { position: fixed; inset: 0; pointer-events: none; opacity: 0; transition: opacity 0.2s; --ov: 92, 186, 166; }
+28
View File
@@ -177,6 +177,34 @@ function drawPreviews() {
ctx.restore(); ctx.restore();
if (t === 'build' && GAME.replace && !sel && !pasting) drawTag('Replace', '255,190,90', p.x + BS * zp * 1.2, p.y + BS * zp * 1.4); if (t === 'build' && GAME.replace && !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); drawCursorLabel(p.x, p.y, t === 'vacuum' ? GAME.brush[GAME.tool] : t === 'ignite' || t === 'drill' ? 1 : 2);
drawToolCursor();
}
// Screen-sized, hollow pointers: keep the exact aim visible between the strokes.
function drawToolCursor() {
if (!GAME.mouse.in || GAME.usingPad || GAME.paused || anyModal()) return;
const t = toolId(), sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld();
const pasting = !sel && GAME.paste && GAME.clip;
if (t === 'drill' && !sel && !pasting) return; // keep the drill's existing cross
const ctx = R.ctx, u = R.canvas.width / window.innerWidth;
const col = sel ? SEL_COL[sel] : pasting ? SEL_COL.copy : t === 'vacuum' ?
(GAME.rmb && !GAME.rmbConsumed ? '255,210,122' : '92,186,166') : t === 'ignite' ? '255,154,60' : '239,230,216';
ctx.save();
ctx.translate(GAME.mouse.x * u, GAME.mouse.y * u); ctx.scale(u, u);
ctx.setLineDash([]); ctx.lineDashOffset = 0; ctx.lineCap = 'butt'; ctx.lineJoin = 'round';
ctx.beginPath();
if (t === 'build' && !sel && !pasting) {
// Split tip converges on (0,0), leaving the aimed grain uncovered.
ctx.moveTo(0, 4); ctx.lineTo(0, 18); ctx.lineTo(4, 13);
ctx.lineTo(8, 20); ctx.lineTo(11, 18); ctx.lineTo(7, 11);
ctx.lineTo(14, 11); ctx.lineTo(3, 2);
} else {
ctx.moveTo(-10, 0); ctx.lineTo(-4, 0); ctx.moveTo(4, 0); ctx.lineTo(10, 0);
ctx.moveTo(0, -10); ctx.lineTo(0, -4); ctx.moveTo(0, 4); ctx.lineTo(0, 10);
}
ctx.strokeStyle = 'rgba(14,12,11,0.9)'; ctx.lineWidth = 3; ctx.stroke();
ctx.strokeStyle = `rgb(${col})`; ctx.lineWidth = 1.25; ctx.stroke();
ctx.restore();
} }
function previewBlock(code, x, y, w, h, stroke = false, direction = -1, bx = -1, by = -1, planned = null) { function previewBlock(code, x, y, w, h, stroke = false, direction = -1, bx = -1, by = -1, planned = null) {
+1 -1
View File
@@ -45,7 +45,7 @@ def main():
if browser: if browser:
subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True) subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True)
# one at a time: two headless browsers at once can starve each other and flake # one at a time: two headless browsers at once can starve each other and flake
for name in ['play.py', 't_seed.py', 't_smooth.py', 't_dev_console.py', 't_caps.py', 't_gpu.py', 't_vacuum_collapse.py', 't_blow_collapse.py', 't_lakes.py', 't_gpu_golden.py', 't_gpu_light.py', 'firefox_smoke.py']: for name in ['play.py', 't_seed.py', 't_cursors.py', 't_smooth.py', 't_dev_console.py', 't_caps.py', 't_gpu.py', 't_vacuum_collapse.py', 't_blow_collapse.py', 't_lakes.py', 't_gpu_golden.py', 't_gpu_light.py', 'firefox_smoke.py']:
results.append(run_py(name)) results.append(run_py(name))
bad = 0 bad = 0
for name, code, out, secs in results: for name, code, out, secs in results:
+120
View File
@@ -0,0 +1,120 @@
"""t89: exact mouse pointers, open aim centres, menus and both-look cursor captures."""
import pathlib, sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / 'tools'))
from use_venv import ensure_playwright; ensure_playwright()
from browser import launch, consume_gpu_option
consume_gpu_option()
ROOT = pathlib.Path(__file__).resolve().parents[1]
fails = []
def check(name, ok):
print(('PASS ' if ok else 'FAIL ') + name)
if not ok: fails.append(name)
with launch(viewport={'width': 1280, 'height': 760}) as (_, page, url):
errors = []
page.on('pageerror', lambda e: errors.append(str(e)))
page.goto(url)
page.wait_for_function('CAPS.ready')
page.evaluate("""() => {
const enter = enterGame; enterGame = () => { enter(); GAME.paused=true; };
SET.look='pixel'; SET.workerCap=0; SET.cameraLead=false; SET.autosave=0;
startNewWorld(7,768,600);
}""")
page.wait_for_function('GAME.running', timeout=60000)
check('game has an exact mouse pointer drawing pass', page.evaluate("typeof drawToolCursor === 'function'"))
if not fails:
page.evaluate("""() => {
SET.fog=false; SET.minimap=false; SET.zoom=6; SET.shake=false;
SET.lighting='off'; SET.glow='off'; devPaused=true; GAME.paused=false;
mat.fill(EMPTY); bgm.fill(0); part.fill(0); mach.fill(0); blockP.fill(0);
machines=[null]; molds=[]; ents=[]; flyers=[]; treeBases=[]; saplings=[];
surfY.fill(260); dirtY.fill(268); fog.fill(255);
for(let y=260;y<H;y++) for(let x=0;x<W;x++) setCell(y*W+x,STONE,20,F_NAT);
bot.x=320; bot.y=220; bot.vx=bot.vy=0;
bag[RSTONE]=500; bagTotal=500; money=5000;
GAME.pieceIdx=PIECES.findIndex(p=>p.id==='wall');
document.querySelectorAll('.card-pop,.toast').forEach(e=>e.remove());
resizeRender(); snapCamera(); GPU.world=null; SM.surf=-1;
}""")
page.mouse.move(711, 343)
# Capture only this pass on a transparent canvas to check the actual raster,
# independent of the world, grid and brush previews underneath it.
page.evaluate("""() => {
window.cursorRaster = () => {
const old=R.ctx, c=document.createElement('canvas');
c.width=R.canvas.width; c.height=R.canvas.height; R.ctx=c.getContext('2d');
try { drawToolCursor(); } finally { R.ctx=old; }
const u=R.canvas.width/innerWidth, x=Math.round(GAME.mouse.x*u), y=Math.round(GAME.mouse.y*u);
const ctx=c.getContext('2d'), data=ctx.getImageData(0,0,c.width,c.height).data;
let n=0; for(let i=3;i<data.length;i+=4) if(data[i]) n++;
const centre=ctx.getImageData(x-1,y-1,3,3).data;
return {n, open:!centre.some((v,i)=>i%4===3 && v),
nearby:ctx.getImageData(x-18*u,y-18*u,40*u,40*u).data.some((v,i)=>i%4===3 && v)};
};
window.cursorCalls=0; const draw=drawToolCursor;
drawToolCursor=()=>{cursorCalls++;draw();};
}""")
for look in ['pixel', 'smooth']:
page.evaluate('look => SET.look=look', look)
page.wait_for_function(f"R.smoothOn === {'true' if look == 'smooth' else 'false'}")
for name, key in [('drill','1'), ('nozzle','2'), ('build','3'), ('igniter','4'), ('copy','c'), ('cut','x'), ('paste','v')]:
page.evaluate('() => {keys.clear();GAME.paste=false;GAME.selDrag=null;GAME.drag=null;GAME.rmb=false;}')
if name == 'paste':
page.evaluate('GAME.clip={bw:2,bh:1,blocks:[[0,0,BP_WALL,RSTONE],[1,0,BP_WALL,RSTONE]],machines:[],molds:[]}')
if name in ['copy','cut']: page.keyboard.down(key)
else: page.keyboard.press(key)
page.wait_for_timeout(100)
if name != 'drill':
raster=page.evaluate('cursorRaster()')
check(f'{look} {name}: visible at mouse with open centre', raster['n'] > 0 and raster['open'] and raster['nearby'])
page.screenshot(path=str(ROOT / f'shots/t89-{look}-{name}.png'))
if name in ['copy','cut']: page.keyboard.up(key)
page.evaluate('cursorCalls=0')
page.wait_for_function('cursorCalls > 2')
check(f'{look}: pointer is called by real frame loop', page.evaluate('cursorCalls > 2'))
page.keyboard.press('Escape') # stop paste
page.evaluate("SET.look='pixel'")
page.wait_for_function('!R.smoothOn')
page.keyboard.press('2')
page.mouse.move(1100,343)
page.mouse.down(button='right')
check('distant blowing keeps pointer at mouse beyond nozzle reach', page.evaluate('!inReach(GAME.aim.x,GAME.aim.y) && cursorRaster().nearby && cursorRaster().open'))
page.screenshot(path=str(ROOT / 'shots/t89-pixel-nozzle-blow.png'))
page.mouse.up(button='right')
page.mouse.move(711,343)
page.keyboard.press('3')
page.mouse.down(); page.mouse.move(785, 367, steps=5)
check('build drag retains pointer and line', page.evaluate('!!GAME.drag && cursorRaster().n>0'))
page.screenshot(path=str(ROOT / 'shots/t89-pixel-build-drag.png'))
page.mouse.up()
for key, name in [('c','copy'),('x','cut')]:
page.keyboard.down(key)
page.mouse.move(711,343); page.mouse.down(); page.mouse.move(785,367,steps=5)
check(f'{name} drag retains pointer',page.evaluate('!!GAME.selDrag && cursorRaster().n>0'))
page.screenshot(path=str(ROOT / f'shots/t89-pixel-{name}-drag.png'))
page.mouse.up(); page.keyboard.up(key)
page.keyboard.press('Escape') # stop pasting the cut pieces
for zoom in [2,12]:
page.evaluate('zoom=>{SET.zoom=zoom;resizeRender();snapCamera();}',zoom)
page.mouse.move(711,440)
check(f'zoom {zoom}: pointer remains small with open aim',page.evaluate('cursorRaster().open && cursorRaster().n>0 && cursorRaster().n<250'))
page.screenshot(path=str(ROOT / f'shots/t89-pixel-build-zoom{zoom}.png'))
page.locator('.bb-tile').first.hover()
check('build menu keeps native pointer', page.locator('.bb-tile').first.evaluate("e=>getComputedStyle(e).cursor==='pointer'"))
check('game pointer hides over menu', page.evaluate('cursorRaster().n===0'))
page.mouse.move(711,343)
page.keyboard.press('Escape')
page.wait_for_function("document.body.classList.contains('paused')")
check('pause hides custom pointer', page.evaluate('cursorRaster().n===0'))
check('canvas behind menu has system pointer', page.locator('#game').evaluate("e=>getComputedStyle(e).cursor==='default'"))
page.screenshot(path=str(ROOT / 'shots/t89-menu.png'))
page.keyboard.press('Escape')
page.mouse.move(711,343)
page.evaluate('GAME.usingPad=true')
check('gamepad does not get a second mouse pointer', page.evaluate('cursorRaster().n===0'))
for scale in [1,2]:
page.evaluate('scale=>{GAME.usingPad=false;R.canvas.width=innerWidth*scale;R.canvas.height=innerHeight*scale;}',scale)
check(f'canvas scale {scale}: exact mouse pointer', page.evaluate('cursorRaster().nearby && cursorRaster().open'))
check('no page errors', not errors)
print('FAILS:', fails)
sys.exit(bool(fails))