From 99aae0aae055386643e938643174d88b7c1fa865 Mon Sep 17 00:00:00 2001 From: 44r0n7 <44r0n7+gitea@pm.me> Date: Thu, 29 Oct 2026 22:59:53 -0400 Subject: [PATCH] Game-drawn cursors for each tool; a real pointer when building (t89) --- docs/DESIGN.md | 6 +++ docs/PROJECT_MAP.md | 5 +- docs/TESTING.md | 4 ++ docs/agent-log.md | 13 +++++ src/styles/style.css | 1 + src/ui/previews.js | 28 ++++++++++ test/run_all.py | 2 +- test/t_cursors.py | 120 +++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 test/t_cursors.py diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 161766d..53c11e5 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -650,3 +650,9 @@ above, including: 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 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. diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index a8d76e2..de29397 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -596,9 +596,9 @@ Gamepad input. - functions: padPressed, pollPad - 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. -- functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, previewBlock +- functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, drawToolCursor, previewBlock ### 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 @@ -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_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_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.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. diff --git a/docs/TESTING.md b/docs/TESTING.md index bac3114..8b694dd 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -94,6 +94,10 @@ screenshots go to `shots/t72-{retrace,escape,open}.png`. ## 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, real text dragging, clamping at 4294967295, zero and empty seeds, keyboard dice activation, automatic/custom names and real generation with seed zero. Both-look diff --git a/docs/agent-log.md b/docs/agent-log.md index 24081d2..b29bc2d 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -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 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) - Added a labeled dice button and shared seed parsing/random rule; ignore non-digits, diff --git a/src/styles/style.css b/src/styles/style.css index 285d4e4..f938225 100644 --- a/src/styles/style.css +++ b/src/styles/style.css @@ -30,6 +30,7 @@ body { -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 */ +body.paused canvas#game { cursor: default; } /* 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 */ #ovframe { position: fixed; inset: 0; pointer-events: none; opacity: 0; transition: opacity 0.2s; --ov: 92, 186, 166; } diff --git a/src/ui/previews.js b/src/ui/previews.js index c44ea69..149a5d8 100644 --- a/src/ui/previews.js +++ b/src/ui/previews.js @@ -177,6 +177,34 @@ function drawPreviews() { 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); 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) { diff --git a/test/run_all.py b/test/run_all.py index 60e3184..779ec21 100644 --- a/test/run_all.py +++ b/test/run_all.py @@ -45,7 +45,7 @@ def main(): if browser: 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 - 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)) bad = 0 for name, code, out, secs in results: diff --git a/test/t_cursors.py b/test/t_cursors.py new file mode 100644 index 0000000..a44587d --- /dev/null +++ b/test/t_cursors.py @@ -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;yp.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;ii%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))