diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index ab59aef..b8758c9 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -183,6 +183,7 @@ The frame loop applies speed and queued steps while keeping the per-frame tick l | Mouse building and dragging | `src/ui/actions.js` (`primaryPress`, `primaryRelease`, `secondaryPress`, `toolTick`) | | Cursor previews (ghosts, drill aim, selection box) | `src/ui/previews.js` | | Controls help text | `src/ui/notebook.js` (`controlsHtml`) | +| Cached vein membership, ore identification and label timing | `src/ui/cursor.js` | | Hover info and discovery cards | `src/ui/info.js` | | Milestones | `src/sim/progress.js` (`MILESTONES`), checked in `src/sim/ledger.js` | | Starter furnace designs | `src/sim/progress.js` (`STARTER_DESIGNS`, `smelterBlueprint`) | @@ -310,7 +311,7 @@ Molds: cavities for castings; ingot troughs empty themselves below. Removal retu - functions: moldCellsAt, canPlaceMold, newMoldRecord, addMold, castMix, cavityState, troughState, clearCavity, moldPlugCells, rakeAbove, furnaceJunkHint, unplugMold, ejectIngot, checkMolds, moldAt, tally, collectMold, removeMold - variables: DROSS, grains, barWord -### src/sim/items.js (317 lines) +### src/sim/items.js (318 lines) Bars and other loose objects (ents), and the bag: adding, taking, costs and refunds. - functions: entSolidCell, entSupportCount, entFreeAt, entMark, spawnBar, removeEnt, entScrewAt, entScrewFree, entScrewMove, entScrewLane, entScrewEdge, entScrewEnter, entScrewTick, entDiagonalScrewTick, entBeltScrewEnter, entSlopeBeltTick, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText - variables: BAR_W, BAR_H, entCurM, droppedLast @@ -375,12 +376,12 @@ World generation helpers: noise and removal of tiny hard rock specks. World generation: staged terrain, ore, caves and placement, with synchronous and async entry points. - functions: generateWorld, generateWorldAsync -### src/sim/devconsole.js (113 lines) +### src/sim/devconsole.js (114 lines) Dev commands are pure simulation operations; the browser owns the panel and confirmation. - functions: resetDevConsole, enableDevConsole, devMaterial, devCommand - variables: devWorld, devLog, devFree, devSpeed, devPaused, devSteps, DEV_HELP, DEV_MATERIALS -### src/sim/save.js (183 lines) +### src/sim/save.js (185 lines) Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration. - functions: serializeWorld, deserializeWorld, startLoadWake, wakeLoadArea, wakeLoadNearBot, loadWakeTick - variables: SAVE_VERSION, TOFF @@ -412,10 +413,10 @@ 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 (321 lines) +### src/render/overlays.js (370 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, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles -- variables: PING_SPEED, PING_FADE +- 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 ### src/render/smooth.js (99 lines) Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution. @@ -435,7 +436,7 @@ Audio: chiptune effects and generative music that changes with depth. Everything - functions: auInit, auVolumes, rateOk, tone, noiseBurst, sfxPlace, playSfx, auAmbient, mtof, zoneForDepth, scaleNote, musicScheduler - variables: AU, SFX, SFX_NEAR, SFX_FAR, ZONES -### src/ui/state.js (75 lines) +### src/ui/state.js (76 lines) Game shell (src/ui/): browser only. This file: settings, the GAME state object, DOM helpers, toasts and tool status. - functions: loadSettings, saveSettings, el, esc, rgbOf, fmtT, toast, setStatus - variables: SET_DEFAULT, SET, TOOLS, ICONS, BAG_ORDER, GAME, keys, hudDirty, $ @@ -478,9 +479,10 @@ Notebook market: prices, orders, claim payments and wallet. Notebook blueprints: starter designs, saved library and code sharing. - functions: renderBlueprintTab, copyCode -### src/ui/hud.js (34 lines) +### src/ui/hud.js (40 lines) The tool hotbar and what is selected. - functions: renderHotbar, toolId, steering, modShift, grainAt, selectTool, bagMaterials, blowList, blowAllList, blowingAll, piece +- variables: IN_BAG_ORDER ### src/ui/build-menu.js (111 lines) The build menu along the bottom: categories, piece tiles and their icons. @@ -500,7 +502,12 @@ Selection panels: current tool card and bag materials. - functions: renderSelCard, renderBag - variables: lastBagKey -### src/ui/info.js (201 lines) +### src/ui/cursor.js (47 lines) +Hover work is shared by the cursor label and the vein outline. +- functions: oreLike, bitNeeded, pingVisible, cursorVein, hoveredVein, cursorLabelAlpha +- variables: CURSOR_ORES, cursorVeinCache, cursorLabelKey, cursorLabelSince + +### src/ui/info.js (208 lines) Discovery cards, hover info (I) and the cursor label. - functions: showCard, howText, discoveryCard, reactionsOf, hoverInfo, renderInfo, drawCursorLabel, pipeContentsText @@ -513,9 +520,9 @@ Keyboard and mouse listeners. - functions: isTyping - variables: canvas -### src/ui/actions.js (254 lines) +### src/ui/actions.js (256 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 -- functions: updateOverlayUI, toggleLens, doInteract, pickUnderCursor, aimBlock, stampOrigin, selModeHeld, startPaste, stopSelectAndPaste, plural, piecesIn, doUndo, endRmbRecord, dragDirFor, blockLabel, primaryPress, primaryRelease, secondaryPress, firePing, toolTick +- functions: updateOverlayUI, toggleLens, doInteract, pickUnderCursor, aimBlock, stampOrigin, selModeHeld, startPaste, stopSelectAndPaste, plural, piecesIn, doUndo, endRmbRecord, dragDirFor, blockLabel, primaryPress, primaryRelease, secondaryPress, pingWait, firePing, toolTick - variables: ovShown, PING_RECHARGE ### src/ui/gamepad.js (60 lines) @@ -546,7 +553,7 @@ Shared world colours, minimap and full map. - functions: resetMap, mapArea, updateMap, drawMap, mapFit, drawFullMap, openMap, zoomMap, zoomMinimap - variables: MAP_S, MAP -### src/ui/loop.js (138 lines) +### src/ui/loop.js (140 lines) The frame loop and boot. - functions: scanVisible, frame, boot - variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT @@ -562,6 +569,7 @@ The frame loop and boot. - `test/t_build.js`: Building: undo (Ctrl+Z), cut, replace a machine with Ctrl, pasting ghosts, and the fan heat warning. - `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_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/src/manifest.json b/src/manifest.json index f86b09a..c82b3d3 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -69,6 +69,7 @@ "ui/selection.js", "ui/selection-pickers.js", "ui/selection-panels.js", + "ui/cursor.js", "ui/info.js", "ui/devconsole.js", "ui/input.js", diff --git a/src/render/overlays.js b/src/render/overlays.js index 58f271f..7a1df2b 100644 --- a/src/render/overlays.js +++ b/src/render/overlays.js @@ -167,6 +167,22 @@ function drawPing() { } ctx.restore(); } +// Edges are built once when the cursor enters a different vein. +function drawHoveredVein() { + if (SET.cursorLabel === 'off' || (!GAME.mouse.in && !GAME.usingPad)) return; + const vein = hoveredVein(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)); + if (!vein) return; + const ctx = R.ctx, zp = R.zp; + ctx.save(); ctx.beginPath(); ctx.rect(0, 0, R.canvas.width, R.canvas.height); ctx.clip(); + ctx.strokeStyle = 'rgba(235,245,255,0.85)'; ctx.lineWidth = 1; + ctx.beginPath(); + for (const e of vein.edges) { + if (e[2] < R.vx0 || e[0] > R.vx0 + R.bw || e[3] < R.vy0 || e[1] > R.vy0 + R.bh) continue; + const a = worldToScreenPx(e[0], e[1]); + ctx.moveTo(a.x, a.y); ctx.lineTo(a.x + (e[2] - e[0]) * zp, a.y + (e[3] - e[1]) * zp); + } + ctx.stroke(); ctx.restore(); +} // While the ping recharges, a thin arc around the bot fills up (only with the drill in hand). function drawPingRecharge() { if (toolId() !== 'drill' || GAME.pingTick === undefined || tick < GAME.pingTick) return; diff --git a/src/ui/cursor.js b/src/ui/cursor.js new file mode 100644 index 0000000..2bab1b8 --- /dev/null +++ b/src/ui/cursor.js @@ -0,0 +1,47 @@ +// Hover work is shared by the cursor label and the vein outline. +const CURSOR_ORES = new Uint8Array(NMAT); +for (let m = 0; m < NMAT; m++) if (PING_VEINS[m]) { CURSOR_ORES[m] = 1; CURSOR_ORES[YLD[m]] = 1; } +function oreLike(m, bar) { return !!(bar || CURSOR_ORES[m] || SOLIDMETAL[m]); } +function bitNeeded(m) { + if (HARD[m] > 6) return 'cannot be drilled'; + return 'needs ' + BIT_TIERS.find(b => b.hard >= HARD[m]).name.toLowerCase(); +} +function pingVisible(x, y) { + const p = GAME.ping; + if (!p || x < 0 || y < 0 || x >= W || y >= H) return false; + if (!p.cellSet) p.cellSet = new Set(p.cells); + if (!p.cellSet.has(y * W + x)) return false; + const start = Math.hypot(x - p.x, y - p.y) / PING_SPEED * 1000, ms = performance.now() - p.t0; + return ms >= start && ms < start + PING_HOLD + PING_FADE; +} +function cursorVein(i) { + const m = mat[i], cells = new Set([i]), queue = [i], edges = []; + const same = q => q >= 0 && q < N && mat[q] === m && (flags[q] & F_NAT); + const add = n => { if (cells.size < 6000 && same(n) && !cells.has(n)) { cells.add(n); queue.push(n); } }; + for (let k = 0; k < queue.length; k++) { + const q = queue[k], x = q % W; + if (x) add(q - 1); + if (x < W - 1) add(q + 1); + add(q - W); add(q + W); + } + for (const q of cells) { + const x = q % W, y = (q / W) | 0; + if (!x || !same(q - 1)) edges.push([x, y, x, y + 1]); + if (x === W - 1 || !same(q + 1)) edges.push([x + 1, y, x + 1, y + 1]); + if (!same(q - W)) edges.push([x, y, x + 1, y]); + if (!same(q + W)) edges.push([x, y + 1, x + 1, y + 1]); + } + return { cells, edges, m, world: mat }; +} +let cursorVeinCache = null, cursorLabelKey = null, cursorLabelSince = 0; +function hoveredVein(x, y) { + if (x < 0 || y < 0 || x >= W || y >= H) return null; + const i = y * W + x, m = mat[i]; + if (!PING_VEINS[m] || !(flags[i] & F_NAT) || (SET.fog && fogSeen(x, y) < 64 && !pingVisible(x, y))) return null; + if (!cursorVeinCache || cursorVeinCache.world !== mat || cursorVeinCache.m !== m || !cursorVeinCache.cells.has(i)) cursorVeinCache = cursorVein(i); + return cursorVeinCache; +} +function cursorLabelAlpha(key, now) { + if (key !== cursorLabelKey) { cursorLabelKey = key; cursorLabelSince = now; } + return Math.max(0, Math.min(1, (2400 - (now - cursorLabelSince)) / 400)); +} diff --git a/src/ui/info.js b/src/ui/info.js index ea798f6..6cc834c 100644 --- a/src/ui/info.js +++ b/src/ui/info.js @@ -77,7 +77,8 @@ function reactionsOf(m, forNotebook) { // 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: [] }; + const pinged = pingVisible(ax, ay); + if (SET.fog && fogSeen(ax, ay) < 64 && !pinged) 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 = ''; @@ -143,8 +144,8 @@ function hoverInfo(ax, ay) { 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'; + t2.push(HARD[m] > 6 ? 'cannot be drilled' : ok ? `drillable (hardness ${HARD[m]})` : bitNeeded(m)); + if (!ok && toolId() === 'drill') warn = bitNeeded(m); } if (!bar && PH[m] === PH_POWDER) t2.push('loose'); if (!id && !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'); } @@ -163,7 +164,8 @@ function hoverInfo(ax, ay) { // 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, contents, air: m === EMPTY && !bgm[i] && !bar && !mo && !id && !p && !b }; + if (pinged) t2.push('found by the ping'); + return { t1, t2, t3, warn, contents, ore: oreLike(m, bar), m: bar ? bar.m : m, 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)); @@ -173,15 +175,19 @@ function renderInfo() { } // 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; + if (SET.cursorLabel === 'off' || (!GAME.mouse.in && !GAME.usingPad)) { cursorLabelKey = null; return; } const info = hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)); - if (!info.t1 || info.air) return; + if (!info.t1 || (info.air && !info.warn) || (SET.cursorLabel === 'ores' && !info.ore && !info.warn)) { cursorLabelKey = null; return; } + const vein = hoveredVein(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)); + const alpha = cursorLabelAlpha(vein || info.m || info.t1, performance.now()); + if (!alpha && !info.warn) 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 + (info.contents && info.warn ? ` ${info.contents}` : ''); + const main = alpha ? info.t1 + (info.t2.includes('found by the ping') ? ' · found by the ping' : '') + (info.contents && info.warn ? ` ${info.contents}` : '') : ''; const sub = info.warn || info.contents || (R.lens ? info.t2.find(t => /°C$/.test(t)) || '' : ''); ctx.save(); + ctx.globalAlpha = info.warn ? 1 : alpha; ctx.font = `600 ${fs}px ${R.uiFont}`; const w1 = ctx.measureText(main).width; ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`; @@ -193,7 +199,8 @@ function drawCursorLabel(px, py, r) { 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); + ctx.font = `600 ${fs}px ${R.uiFont}`; ctx.fillStyle = '#efe6d8'; ctx.globalAlpha = alpha; ctx.fillText(main, x + padX, y + h / 2); + ctx.globalAlpha = info.warn ? 1 : alpha; if (sub) { ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`; ctx.fillStyle = info.warn ? '#ef6a55' : info.contents ? '#efe6d8' : '#ffd27a'; ctx.fillText(sub, x + padX + w1 + fs * 0.6, y + h / 2); } ctx.restore(); } diff --git a/src/ui/loop.js b/src/ui/loop.js index a0e846b..b9dc727 100644 --- a/src/ui/loop.js +++ b/src/ui/loop.js @@ -74,6 +74,7 @@ function frame(now) { if (GAME.infoOverlay) { drawFlowArrows(); drawFlowRates(); } drawJoinMarks(); drawPing(); + drawHoveredVein(); drawPingRecharge(); drawFilterTags(!!GAME.infoOverlay, GAME.editRun ? GAME.editRun[0] : hb); drawFloaters(); diff --git a/src/ui/settings.js b/src/ui/settings.js index b799bf6..901cf76 100644 --- a/src/ui/settings.js +++ b/src/ui/settings.js @@ -8,7 +8,7 @@ const SET_TABS = { { k: 'uiScale', label: 'Interface size', type: 'range', min: 0.8, max: 1.4, step: 0.05, fmt: v => Math.round(v * 100) + '%' }, { k: 'fpsCap', label: 'Frame cap', d: 'Uncapped follows your display refresh rate.', type: 'select', opts: [[30, '30 fps'], [60, '60 fps'], [120, '120 fps'], [0, 'Uncapped']] }, { k: 'fullscreen', label: 'Fullscreen', type: 'button', text: 'Toggle fullscreen', fn: toggleFullscreen }, - { k: 'cursorLabel', label: 'Name next to the cursor', d: 'Shows what you are pointing at right beside the cursor.', type: 'toggle' }, + { k: 'cursorLabel', label: 'Name next to the cursor', d: 'Briefly names ores and metals, or everything. Warnings stay visible.', type: 'select', opts: [['off', 'Off'], ['ores', 'Ores only'], ['all', 'Everything']] }, { k: 'minimap', label: 'Show minimap', type: 'toggle' }, { k: 'showPerf', label: 'Performance stats', d: 'Frame time, simulation time and active areas.', type: 'toggle' }, { k: 'cameraSmooth', label: 'Camera smoothing', type: 'range', min: 0, max: 0.95, step: 0.05, fmt: v => Math.round(v * 100) + '%' }, diff --git a/src/ui/state.js b/src/ui/state.js index 29df6d4..d12a0f2 100644 --- a/src/ui/state.js +++ b/src/ui/state.js @@ -7,13 +7,14 @@ const SET_DEFAULT = { look: 'smooth', lighting: 'full', lightEvery: 1, ambient: 0.12, daylight: 1, glow: 'low', grain: 1, incandescence: true, particles: true, minimap: true, minimapZoom: 4, fog: true, thermalPalette: 'inferno', simSpeed: 1, maxSteps: 3, volMaster: 0.8, volMusic: 0.5, volSfx: 0.8, muted: false, muteUnfocused: true, - deadzone: 0.18, aimSpeed: 1, autosave: 3, cardSeconds: 9, cursorLabel: true, sv: 2, + deadzone: 0.18, aimSpeed: 1, autosave: 3, cardSeconds: 9, cursorLabel: 'ores', sv: 2, }; const SET = Object.assign({}, SET_DEFAULT); // sv: settings version. 2: the default zoom became fully zoomed in, so older saved settings move there once. function loadSettings() { try { const s = JSON.parse(localStorage.getItem('deepgrain.settings') || '{}'); Object.assign(SET, s); + if (typeof SET.cursorLabel === 'boolean') SET.cursorLabel = SET.cursorLabel ? 'ores' : 'off'; if (!(s.sv >= 2)) { SET.zoom = SET_DEFAULT.zoom; SET.sv = 2; if (Object.keys(s).length) saveSettings(); } } catch (e) { /* storage unavailable */ } } diff --git a/test/play.py b/test/play.py index db5fbee..b996932 100644 --- a/test/play.py +++ b/test/play.py @@ -1215,7 +1215,7 @@ with sync_playwright() as p: fp.wait_for_function('GAME.running') # Silo feedback uses real pointer positions; drawing checks sample an isolated canvas. fp.evaluate("""() => { - GAME.paused = true; SET.fog = false; SET.look = 'pixel'; SET.zoom = 8; SET.cursorLabel = true; + GAME.paused = true; SET.fog = false; SET.look = 'pixel'; SET.zoom = 8; SET.cursorLabel = 'all'; SET.cameraLead = false; SET.cameraSmooth = 0; resizeRender(); for (let y = 80; y < 140; y++) for (let x = 80; x < 160; x++) { setCell(y * W + x, EMPTY, 20, 0); bgm[y * W + x] = 0; } window.testSilo = addMachine('silo', 28, 25, 0); diff --git a/test/t_cursor.js b/test/t_cursor.js new file mode 100644 index 0000000..4e02835 --- /dev/null +++ b/test/t_cursor.js @@ -0,0 +1,58 @@ +// Ore identification, cached connected veins, ping timing, settings and label lifetime. +const fs = require('fs'); +const path = require('path'); +const { load, suite, flatWorld } = require('./harness'); +const { run } = load(); +const { check, done } = suite(); +run(`const localStorage = { getItem: () => '{}', setItem() {} };`); +for (const f of ['ui/state.js', 'ui/hud.js', 'ui/cursor.js', 'ui/info.js', 'render/overlays.js']) run(fs.readFileSync(path.join(__dirname, '../src', f), 'utf8')); +flatWorld(run, 160, 160, 150); +for (const m of ['CUV', 'CUORE', 'COALV', 'COAL', 'FEV', 'FEORE', 'COPPER']) + check(m + ' is ore-like', run(`oreLike(${m}, null)`)); +check('sand and stone are not ore-like', run('!oreLike(SAND, null) && !oreLike(STONE, null)')); +check('any hovered bar is ore-like', run('oreLike(EMPTY, { m: BARGL })')); +check('default labels are ores only', run("SET.cursorLabel === 'ores'")); +for (const [saved, expected] of [[true, 'ores'], [false, 'off'], ['all', 'all']]) { + run(`localStorage.getItem = () => '${JSON.stringify({cursorLabel: saved, sv: 2})}'; loadSettings();`); + check('saved ' + saved + ' becomes ' + expected, run(`SET.cursorLabel === '${expected}'`)); +} +check('bit text names the first tier that can drill it', run("BIT_TIERS.every(b => { const m = Array.from(HARD).findIndex(h => h === b.hard); return m < 0 || bitNeeded(m) === 'needs ' + b.name.toLowerCase(); })")); +check('undrillable material keeps its warning', run("bitNeeded(Array.from(HARD).findIndex(h => h > 6)) === 'cannot be drilled'")); +run(` + SET.fog = false; GAME.tool = 0; + setCell(20 * W + 20, CUV, 22, F_NAT); + setCell(20 * W + 21, CUV, 22, F_NAT); + setCell(21 * W + 21, CUV, 22, F_NAT); + setCell(21 * W + 20, FEV, 22, F_NAT); + setCell(20 * W + 22, CUV, 22, 0); + setCell(22 * W + 22, CUV, 22, F_NAT); + const v = hoveredVein(20, 20); +`); +check('fill follows four neighbours of the same natural material', run('v.cells.size === 3 && v.edges.length === 8')); +check('moving within the same vein reuses cells and edges', run('hoveredVein(21, 21) === v')); +check('leaving the vein removes the outline', run('hoveredVein(22, 20) === null')); +check('a disconnected vein gets a new cache', run('hoveredVein(22, 22) !== v')); +run(`for (let y = 30; y < 130; y++) for (let x = 0; x < 100; x++) setCell(y * W + x, CUV, 22, F_NAT);`); +check('a large vein stops at 6000 cells', run('cursorVein(30 * W).cells.size === 6000')); +run(`setCell(W - 1, CUV, 22, F_NAT); setCell(W, CUV, 22, F_NAT);`); +check('fill does not wrap across world edges', run('cursorVein(W - 1).cells.size === 1')); +run(`setCell(20 * W + 20, FEV, 22, F_NAT);`); +check('hover details and warning name the needed bit', run("hoverInfo(20, 20).t2.includes(bitNeeded(FEV)) && hoverInfo(20, 20).warn === bitNeeded(FEV)")); +run(` + SET.fog = true; fog.fill(0); + const realNow = performance.now.bind(performance); let hoverNow = 1000; + performance.now = () => hoverNow; + GAME.ping = { x: 4, y: 20, t0: 1000, cells: [20 * W + 20] }; +`); +check('fog hides a vein before the ping reaches it', run("hoverInfo(20, 20).t1 === 'Unexplored' && !hoveredVein(20, 20)")); +run('hoverNow = 1100;'); +check('a ping names a hidden vein at arrival', run("hoverInfo(20, 20).t1 === MD[FEV].name && hoverInfo(20, 20).t2.includes('found by the ping') && !!hoveredVein(20, 20)")); +run('const pingSet = GAME.ping.cellSet; hoverNow = 3099;'); +check('ping cells are cached through the fade', run('pingVisible(20, 20) && GAME.ping.cellSet === pingSet && !pingVisible(21, 20)')); +run('hoverNow = 3100;'); +check('fog hides the vein again at the end of the fade', run("hoverInfo(20, 20).t1 === 'Unexplored' && !hoveredVein(20, 20)")); +run('performance.now = realNow;'); +check('the name stays fully visible for two seconds', run("cursorLabelAlpha('copper', 100) === 1 && cursorLabelAlpha('copper', 2100) === 1")); +check('the name fades over 400 ms', run("cursorLabelAlpha('copper', 2300) === 0.5 && cursorLabelAlpha('copper', 2500) === 0")); +check('a different thing brings the label back', run("cursorLabelAlpha('iron', 2600) === 1")); +done(); diff --git a/test/t_smooth.py b/test/t_smooth.py index 84190bf..392ea04 100644 --- a/test/t_smooth.py +++ b/test/t_smooth.py @@ -76,6 +76,58 @@ with sync_playwright() as p: check('built solids get no loose-grain outline', ground['built'][0] == ground['built'][1], ground['built']) check('smooth: a thin darker seam separates ground and rubble', ground['smooth'][0] < ground['smooth'][1] * 0.9, ground['smooth']) check('smooth: packed rubble keeps pebble shading', ground['texture'] > 30, ground['texture']) + # Cursor labels and outlines use a paused scene and the real drawing functions. + cursor = ev("""() => { + SET.look='pixel'; SET.cursorLabel='ores'; SET.fog=false; SET.zoom=8; + resizeRender(); GAME.tool=0; GAME.mouse.in=true; GAME.usingPad=false; + bot.x=118; bot.y=114; snapCamera(); player.lens=false; R.lens=false; + for(let y=110;y<130;y++)for(let x=110;x<140;x++) { + const i=y*W+x; setCell(i,STONE,22,F_NAT); part[i]=0; mach[i]=0; occ[i]=0; bgm[i]=0; + blockP[(y>>2)*BW+(x>>2)]=0; + } + for(let y=118;y<126;y++)for(let x=120;x<132;x++) + if(x+y>239 && x+y<255)setCell(y*W+x,CUV,22,F_NAT); + setCell(116*W+116,SAND,22,0); + GAME.aim={x:124,y:122}; cursorLabelKey=null; + const ctx=R.ctx,text=ctx.fillText,stroke=ctx.stroke,now=performance.now.bind(performance); + let time=1000,calls=[],strokes=0; + performance.now=()=>time; + ctx.fillText=function(t){calls.push([t,this.globalAlpha,this.fillStyle]);}; + ctx.stroke=function(){strokes++;}; + const label=()=>{calls=[];drawCursorLabel(500,300,1);return calls.slice();}; + try { + GAME.aim={x:116,y:116};const sand=label(); + GAME.aim={x:124,y:122};const copper=label();drawHoveredVein();const outlined=strokes; + const vein=cursorVeinCache; + GAME.aim={x:125,y:122};time=3200;const fading=label(); + const reused=cursorVeinCache===vein; + time=3400;const faded=label(); + setCell(116*W+117,FEV,22,F_NAT); GAME.aim={x:117,y:116}; + const hard=label();time=6000;const warning=label(); + GAME.aim={x:124,y:122}; SET.fog=true;fog.fill(0); + GAME.ping={x:124,y:122,t0:6000,r:48,cells:[122*W+124]}; + const ping=hoverInfo(124,122),pingLabel=label();renderInfo(); + const panel=$('info').textContent; + time=8000;const hidden=hoverInfo(124,122).t1; + GAME.ping=null;SET.fog=false; SET.cursorLabel='off'; + const off=label();strokes=0;drawHoveredVein(); + return {sand,copper,outlined,reused,fading,faded,hard,warning,ping,pingLabel,panel,hidden,off,offStrokes:strokes}; + } finally {ctx.fillText=text;ctx.stroke=stroke;performance.now=now;} + }""") + check('ores mode skips sand and draws the copper vein name', not cursor['sand'] and any(t[0] == 'Copper ore vein' for t in cursor['copper']), cursor['copper']) + check('hovering a vein draws an outline and reuses it within the vein', cursor['outlined'] > 0 and cursor['reused']) + check('the name fades then disappears while staying on the vein', any(0 < t[1] < 1 for t in cursor['fading']) and not cursor['faded'], cursor['fading']) + check('the needed bit stays red after the name fades', any(t[0].startswith('needs ') and t[1] == 1 and t[2] == '#ef6a55' for t in cursor['warning']), cursor['warning']) + check('a pinged vein under fog names itself in the label and corner panel', cursor['ping']['t1'] == 'Copper ore vein' and 'found by the ping' in cursor['panel'] and any('found by the ping' in t[0] for t in cursor['pingLabel']), cursor['pingLabel']) + check('a faded ping hides the vein again', cursor['hidden'] == 'Unexplored') + check('off hides both label and vein outline', not cursor['off'] and cursor['offStrokes'] == 0) + for look in ['pixel', 'smooth']: + ev("""look => { + SET.look=look; SM.scale=1; SET.cursorLabel='ores'; GAME.aim={x:124,y:122}; cursorLabelKey=null; + $('cards').replaceChildren(); renderInfo(); drawWorld(); drawHoveredVein(); drawBot(); + const p=worldToScreenPx(124,122); drawCursorLabel(p.x,p.y,1); + }""", look) + pg.screenshot(path=str(root / 'shots' / ('t77-vein.png' if look == 'pixel' else 't77-vein-smooth.png'))) wall = pg.evaluate('''() => { GAME.paused = true; R.lens = false; SET.fog = false; SET.lighting = 'off'; SET.glow = 'off'; SET.grain = false;