diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index c1acba7..0f9127f 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -196,10 +196,14 @@ Deepgrain Foundry: simulation core (src/sim/). No DOM anywhere in src/sim/: plai - functions: rnd, mulberry32, hash2 - variables: RS, PH_EMPTY, PH_SOLID, PH_POWDER, PH_LIQUID, PH_GAS, PH_ITEM -### src/sim/materials.js (168 lines) -Material ids, the material table (def), per-material property arrays, cell flags and part ids, and drill/tank tiers. +### src/sim/materials.js (119 lines) +Material definitions: ids, properties and metal families. ids, and drill/tank tiers. - functions: def, metalFamily -- variables: EMPTY, GRASS, DIRT, LDIRT, SAND, GRAVEL, CLAY, STONE, GRANITE, BASALT, NMAT, MD, FIRETO, PH, DENS, KC, ICAP, CASTMETAL, METAL_TIER, SOLIDMETAL, F_NAT, F_CAST, F_BURN, P_NONE, P_TU, P_TD, P_TL, P_TR, P_GRATE, P_GOPEN, P_WHEEL, P_INTAKE, P_FILTER, P_PIPE, TDX, TDY, BIT_TIERS, TANK_TIERS, REACH +- variables: EMPTY, GRASS, DIRT, LDIRT, SAND, GRAVEL, CLAY, STONE, GRANITE, BASALT, NMAT, MD, FIRETO + +### src/sim/material-tables.js (49 lines) +Material tables: typed properties, casting groups, flags, parts and tool tiers. Typed property tables +- variables: PH, DENS, KC, ICAP, CASTMETAL, METAL_TIER, SOLIDMETAL, F_NAT, F_CAST, F_BURN, P_NONE, P_TU, P_TD, P_TL, P_TR, P_GRATE, P_GOPEN, P_WHEEL, P_INTAKE, P_FILTER, P_PIPE, TDX, TDY, BIT_TIERS, TANK_TIERS, REACH ### src/sim/pieces.js (143 lines) Everything the player can build (PIECES): blocks, machines and molds, with costs and descriptions; filter settings; wall materials and their cost. @@ -216,9 +220,9 @@ Starter furnace designs and the milestone list shown in the notebook. - functions: smelterBlueprint - variables: STARTER_DESIGNS, MILESTONES -### src/sim/world.js (101 lines) +### src/sim/world.js (102 lines) World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking. -- functions: emit, bagCap, discover, alloc, depthU, depthY, computeAmbient, fillNoise, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx +- functions: emit, bagCap, discover, alloc, depthU, depthY, meanSurface, computeAmbient, fillNoise, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx - variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, awake, hot, hotLvl, powderArea, roofDirty, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, tubeLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats ### src/sim/fog.js (52 lines) @@ -265,18 +269,18 @@ How belts, filters and screw tubes move what is on or in them. What rests on a b - functions: carryColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, setFilter, filterRun, roomFor, tubeSpread, tubeIntake - variables: CARRY_DEPTH, filterR, filterL, filtersDirty -### src/sim/blocks.js (149 lines) +### src/sim/blocks.js (156 lines) Building blocks: placing, integrity, removing, lines and rectangles of blocks. -- functions: blockOf, blockCellList, pieceCost, canPlaceBlock, placeBlock, blockIntegrity, removeBlock, blockLine, blockRect, blockInReach, blockIs, placeBlocks +- functions: blockOf, blockCellList, pieceCost, canPlaceBlock, placeBlock, blockIntegrity, removeWallCell, removeBlock, blockLine, blockRect, blockInReach, blockIs, placeBlocks -### src/sim/molds.js (319 lines) +### src/sim/molds.js (317 lines) Molds: cavities for castings; ingot troughs empty themselves below. Removal returns set metal. - 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 (225 lines) +### src/sim/items.js (232 lines) Bars and other loose objects (ents), and the bag: adding, taking, costs and refunds. -- functions: entSolidCell, entSupportCount, entFreeAt, entMark, spawnBar, removeEnt, entTubeAt, entTubeFree, entTubeMove, entTubeEnter, entTubeTick, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText +- functions: entSolidCell, entSupportCount, entFreeAt, entMark, spawnBar, removeEnt, entTubeAt, entTubeFree, entTubeMove, entTubeLane, entTubeEdge, entTubeEnter, entTubeTick, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText - variables: BAR_W, BAR_H, entCurM, droppedLast ### src/sim/tools.js (52 lines) @@ -329,19 +333,23 @@ Weather: clouds from steam, rain and snow, and the world water count. simTick: one simulation step, in order. - functions: simTick -### src/sim/worldgen.js (346 lines) -World generation: noise, layers, ores, caves, water, trees, and clearing tiny hard specks. -- functions: makeNoise, fbm, clearHardSpecks, generateWorld, generateWorldAsync +### src/sim/worldgen-helpers.js (61 lines) +World generation helpers: noise and removal of tiny hard rock specks. +- functions: makeNoise, fbm, clearHardSpecks - variables: SPECK_MIN +### src/sim/worldgen.js (286 lines) +World generation: staged terrain, ore, caves and placement, with synchronous and async entry points. +- functions: generateWorld, generateWorldAsync + ### src/sim/save.js (141 lines) Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration. - functions: serializeWorld, deserializeWorld - variables: SAVE_VERSION, TOFF -### src/render/setup.js (196 lines) +### src/render/setup.js (200 lines) Drawing (src/render/): browser only. This file: the renderer state R, colour tables (glow, thermal palettes, pipes, trees, sky), canvas setup and the camera. -- functions: glowIndex, thermIdx, pipeCellColor, pipeBodyCell, thinPipeColor, beltLike, buildSky, initRender, resizeRender, viewCells, updateCamera, snapCamera, screenToWorld, worldToScreenPx +- functions: glowIndex, thermIdx, pipeCellColor, pipeBodyCell, thinPipeColor, beltLike, buildSky, initRender, resizeRender, viewCells, updateCamera, snapCamera, screenToWorld, worldToScreenPx, renderMonoFont - variables: R, GLOW_W, GLOW_R, GLOW_G, GLOW_B, THERM, PC, TREE_R, TREE_G, TREE_B, BG, SKY_R, SKY_G, SKY_B ### src/render/light.js (79 lines) @@ -361,10 +369,14 @@ Machine overlays: fans, engines, silo and the rest, drawn on top of the world. Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles. - functions: spawnFloater, drawRain, drawFlowArrows, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles -### src/render/smooth.js (430 lines) -Smooth look: a WebGL2 pass that turns the per-grain buffers drawWorld fills (colour + class, colour behind, trees, occupancy fields, light, glow) into rounded, outlined, softly lit shapes at screen resolution. The 2D canvas then draws this image as its world layer, and every overlay (machines, bot, previews, labels) goes on top as before. +### src/render/smooth.js (99 lines) +Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution. - functions: smCompile, smoothAvailable, smUpload, smoothDraw, smoothPace -- variables: SM, SM_VS, SM_FS, smBytes +- variables: SM, smBytes + +### src/render/smooth-shaders.js (325 lines) +Smooth shaders: vertex and fragment programs for the rounded world look. +- variables: SM_VS, SM_FS ### src/ui/audio.js (230 lines) Audio: chiptune effects and generative music that changes with depth. Everything is synthesized with WebAudio, no samples. @@ -400,10 +412,19 @@ The settings screen. - functions: openSettings, renderSettings, applySettings, toggleFullscreen, confirmWipe - variables: SET_TABS, setTab -### src/ui/notebook.js (204 lines) -The notebook (N): milestones, market, wallet, blueprints, controls. -- functions: openNotebook, renderNotebook, fmtClock, orderName, orderLeft, renderMarketTab, fmtPrice, itemsText, renderWallet, renderBlueprintTab, copyCode, controlsHtml -- variables: nbTab, MARKET_GROUPS +### src/ui/notebook.js (91 lines) +Notebook: tab navigation, ledger, materials, pieces and controls. +- functions: openNotebook, renderNotebook, controlsHtml +- variables: nbTab + +### src/ui/notebook-market.js (68 lines) +Notebook market: prices, orders, claim payments and wallet. +- functions: fmtClock, orderName, orderLeft, renderMarketTab, fmtPrice, itemsText, renderWallet +- variables: MARKET_GROUPS + +### src/ui/notebook-blueprints.js (47 lines) +Notebook blueprints: starter designs, saved library and code sharing. +- functions: renderBlueprintTab, copyCode ### src/ui/hud.js (34 lines) The tool hotbar and what is selected. @@ -414,14 +435,22 @@ The build menu along the bottom: categories, piece tiles and their icons. - functions: pieceIcon, catOf, selectBuildCat, cycleBuildCat, buildVariantChips, renderBuildBar, setupBuildBar - variables: BUILD_CATS, BUILD_ORDER, PIECE_ICONS, lastBuildKey, lastBuildMenu -### src/ui/selection.js (242 lines) -The selected tool and piece: R / Q / E, drill width, filter picker, the selection card, the bag panel. -- functions: pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleBlow, pickFilterUnderCursor, toggleFilterMat, setBrush, pieceV, pieceExtra, filterChoices, matList, filterParts, filterSentence, applyFilterRun, openFilterPicker, openWallPicker, filterBlockAt, chipCost, pieceCostNow, renderSelCard, renderBag +### src/ui/selection.js (96 lines) +Tool selection: cycling, drill tuning, piece variants and costs. +- functions: pieceSize, pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleBlow, pickFilterUnderCursor, toggleFilterMat, setBrush, pieceV, pieceExtra, filterBlockAt, chipCost, pieceCostNow + +### src/ui/selection-pickers.js (59 lines) +Selection pickers: filter rules, filter settings and wall materials. What a filter can hold: loose things and bars you have found, plus anything it already holds +- functions: filterChoices, matList, filterParts, filterSentence, applyFilterRun, openFilterPicker, openWallPicker + +### src/ui/selection-panels.js (90 lines) +Selection panels: current tool card and bag materials. +- functions: renderSelCard, renderBag - variables: lastBagKey -### src/ui/info.js (186 lines) +### src/ui/info.js (188 lines) Discovery cards, hover info (I) and the cursor label. -- functions: showCard, howText, discoveryCard, reactionsOf, hoverInfo, renderInfo, drawCursorLabel +- functions: showCard, howText, discoveryCard, reactionsOf, hoverInfo, renderInfo, drawCursorLabel, pipeContentsText ### src/ui/input.js (91 lines) Keyboard and mouse listeners. @@ -438,10 +467,19 @@ Gamepad input. - functions: padPressed, pollPad - variables: PAD -### src/ui/previews.js (371 lines) -Previews drawn under the cursor: build ghosts, drill aim and pulses, selections, paste. -- functions: blockRectPx, drawGrid, previewColor, crackSeg, drawCracks, pulseRGB, fanPoints, drawDrillPulses, drawDrillAim, drawAirStreaks, drawPreviews, drawTag, drawSelection, drawPastePreview -- variables: CRACK_N, CRACK_LV, SEL_COL +### src/ui/previews.js (190 lines) +Cursor previews: shared grid helpers, air streaks and tool preview dispatch. +- functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews + +### src/ui/preview-drill.js (104 lines) +Drill previews: shaken rock cracks, pulse wavefronts and aiming fan. Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking. Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines run on unbroken from one cell to the next and look like real fractures, not a pattern. +- functions: crackSeg, drawCracks, pulseRGB, fanPoints, drawDrillPulses, drawDrillAim +- variables: CRACK_N, CRACK_LV + +### src/ui/preview-selection.js (80 lines) +Selection previews: labelled tags, copy and cut boxes, and paste ghosts. a small labelled tag drawn on the world (screen pixels) +- functions: drawTag, drawSelection, drawPastePreview +- variables: SEL_COL ### src/ui/events.js (42 lines) Events from the simulation (emit) turned into sounds, toasts and cards. diff --git a/docs/agent-log.md b/docs/agent-log.md index 99ff33f..bce35e7 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,20 @@ 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-24: t21 phase 4: duplicates merged, largest files split (Claude Code, Codex) + +- Merged seven real duplicates: wall refunds (`removeWallCell`, walls and molds), tube edges and + lanes (`entTubeEdge`, `entTubeLane`), pipe text, piece dimensions, font lookup, reach circles, + surface averaging (`meanSurface`). Look-alikes left alone: data tables, sprite rows, GLSL, and + the hot movement and heat code. +- Split by topic: `ui/previews.js` -> `preview-drill.js`, `preview-selection.js`; + `render/smooth.js` -> `smooth-shaders.js`; `ui/notebook.js` -> `notebook-market.js`, + `notebook-blueprints.js`; `sim/materials.js` -> `material-tables.js`; `sim/worldgen.js` -> + `worldgen-helpers.js`; `ui/selection.js` -> `selection-pickers.js`, `selection-panels.js`. + `render/world.js` left whole (one large function). +- Every suite's output identical apart from timings; seeds generate the same worlds. + `run_all.py --browser` ALL PASSED. + ## 2026-10-24: t21 phase 3: speed-ups that change nothing (Claude Code, Codex) - Profiled a Huge world (6144x2048) headless and in the browser. After a load every area is diff --git a/src/manifest.json b/src/manifest.json index 4979fff..532c951 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -6,6 +6,7 @@ "sim": [ "sim/base.js", "sim/materials.js", + "sim/material-tables.js", "sim/pieces.js", "sim/economy.js", "sim/progress.js", @@ -33,6 +34,7 @@ "sim/ledger.js", "sim/weather.js", "sim/tick.js", + "sim/worldgen-helpers.js", "sim/worldgen.js", "sim/save.js" ], @@ -43,6 +45,7 @@ "render/machines.js", "render/overlays.js", "render/smooth.js", + "render/smooth-shaders.js", "ui/audio.js", "ui/state.js", "ui/title.js", @@ -51,14 +54,20 @@ "ui/session.js", "ui/settings.js", "ui/notebook.js", + "ui/notebook-market.js", + "ui/notebook-blueprints.js", "ui/hud.js", "ui/build-menu.js", "ui/selection.js", + "ui/selection-pickers.js", + "ui/selection-panels.js", "ui/info.js", "ui/input.js", "ui/actions.js", "ui/gamepad.js", "ui/previews.js", + "ui/preview-drill.js", + "ui/preview-selection.js", "ui/events.js", "ui/map.js", "ui/loop.js" diff --git a/src/render/machines.js b/src/render/machines.js index 9bf7879..bd99a23 100644 --- a/src/render/machines.js +++ b/src/render/machines.js @@ -22,7 +22,7 @@ function drawMachines() { ctx.fillStyle = 'rgba(30,28,26,0.85)'; ctx.fillRect(sx + 2 * zp, sy + 13 * zp, 20 * zp, zp); ctx.fillStyle = 'rgba(150,160,170,0.8)'; for (let k = 0; k < 20; k += 2) ctx.fillRect(sx + (2 + k) * zp, sy + 13 * zp, zp * 0.5, zp); - if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace'; + renderMonoFont(); const fs = Math.max(9, Math.round(zp * 2.6)); ctx.font = `700 ${fs}px ${R.monoFont}`; const label = 'TRADE DOCK', tw = ctx.measureText(label).width + fs; @@ -155,7 +155,7 @@ function drawSilo(ctx, mm, sx, sy, zp) { for (let k = 1; k < 4; k++) { const yy = Y(gy1 - gh * k / 4); ctx.beginPath(); ctx.moveTo(X(gx - 0.35), yy); ctx.lineTo(X(gx), yy); ctx.stroke(); } // what it holds, stencilled on the drum if (zp >= 3) { - if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace'; + renderMonoFont(); const fs = Math.max(8, Math.round(zp * 1.15)); ctx.font = `700 ${fs}px ${R.monoFont}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'rgba(20,24,28,0.75)'; @@ -201,7 +201,7 @@ function drawSiloPixel(ctx, mm, sx, sy, zp) { const lid = tick - (mm.fillT || -99) < 10 ? '#e0b43c' : '#9aa4ae'; grain(8, 0, lid); grain(9, -1, lid); if (zp >= 3) { - if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace'; + renderMonoFont(); ctx.save(); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'rgba(20,24,28,0.75)'; ctx.font = `700 ${Math.max(8, Math.round(zp * 1.15))}px ${R.monoFont}`; ctx.fillText('SILO', sx + 5.6 * zp, sy + 5 * zp); diff --git a/src/render/overlays.js b/src/render/overlays.js index eed5880..9548b9d 100644 --- a/src/render/overlays.js +++ b/src/render/overlays.js @@ -73,7 +73,7 @@ function drawFilterTags(showAll, hoverB) { const v = viewCells(); const bx0 = Math.max(0, Math.floor(R.camX / BS) - 1), bx1 = Math.min(BW - 1, Math.floor((R.camX + v.w) / BS) + 1); const by0 = Math.max(0, Math.floor(R.camY / BS) - 1), by1 = Math.min(BH - 1, Math.floor((R.camY + v.h) / BS) + 1); - if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace'; + renderMonoFont(); ctx.save(); ctx.font = `600 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.textBaseline = 'middle'; const sw = Math.round(9 * u), gap = Math.round(3 * u), pad = Math.round(5 * u), th = Math.round(17 * u); @@ -125,7 +125,7 @@ function drawFilterTags(showAll, hoverB) { function drawFloaters() { const fl = R.floaters; if (!fl || !fl.length) return; const ctx = R.ctx, zp = R.zp; - if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace'; + renderMonoFont(); ctx.save(); ctx.font = `700 ${Math.max(11, Math.round(zp * 3))}px ${R.monoFont}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; for (let k = fl.length - 1; k >= 0; k--) { @@ -141,7 +141,7 @@ function moldLabel(mo) { return mo.state === 'ready' && mo.kind !== 'ingot' ? '1 function drawMolds() { const ctx = R.ctx, zp = R.zp, fs = Math.max(9, Math.round(zp * 2)); ctx.save(); - if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace'; + renderMonoFont(); ctx.font = `600 ${fs}px ${R.monoFont}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; for (const mo of molds) { diff --git a/src/render/setup.js b/src/render/setup.js index a3a05e6..bdeb7de 100644 --- a/src/render/setup.js +++ b/src/render/setup.js @@ -129,7 +129,7 @@ const BG = { let SKY_R, SKY_G, SKY_B; function buildSky() { SKY_R = new Uint8Array(H); SKY_G = new Uint8Array(H); SKY_B = new Uint8Array(H); - let sm = 0; for (let x = 0; x < W; x++) sm += surfY[x]; sm /= W; + const sm = meanSurface(); for (let y = 0; y < H; y++) { const f = Math.min(1, y / (sm + 20)); SKY_R[y] = 88 + f * 92; SKY_G[y] = 140 + f * 70; SKY_B[y] = 198 + f * 34; @@ -194,3 +194,7 @@ function screenToWorld(sx, sy) { return { x: R.camX + sx * dpr / R.zp, y: R.camY + sy * dpr / R.zp }; } function worldToScreenPx(x, y) { return { x: (x - R.camX) * R.zp, y: (y - R.camY) * R.zp }; } + +function renderMonoFont() { + if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace'; +} diff --git a/src/render/smooth-shaders.js b/src/render/smooth-shaders.js new file mode 100644 index 0000000..d448efb --- /dev/null +++ b/src/render/smooth-shaders.js @@ -0,0 +1,325 @@ +// Smooth shaders: vertex and fragment programs for the rounded world look. +const SM_VS = `#version 300 es +void main() { + vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +}`; + +const SM_FS = `#version 300 es +precision highp float; +precision highp int; +uniform sampler2D tCol, tColL, tBack, tTree, tField, tLight, tGlow; +uniform vec2 uSize, uOrigin, uLightSize, uGlowSize; +uniform vec4 uView, uLightMap; +uniform float uGlowK, uTime, uSurf, uRain; +out vec4 o; + +float h21(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); } +float vn(vec2 p) { + vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f); + return mix(mix(h21(i), h21(i + vec2(1, 0)), f.x), mix(h21(i + vec2(0, 1)), h21(i + vec2(1, 1)), f.x), f.y); +} +float fbm(vec2 p) { float a = 0.5, s = 0.0; for (int k = 0; k < 4; k++) { s += a * vn(p); p = p * 2.03 + 17.1; a *= 0.5; } return s / 0.9375; } + +vec4 lin(sampler2D t, vec2 q) { return textureLod(t, q / uSize, 0.0); } +// cubic B-spline from four bilinear taps: rounds a field of 0s and 1s into smooth shapes +vec4 bsp(sampler2D t, vec2 q) { + vec2 tc = q - 0.5, i = floor(tc), f = tc - i, f2 = f * f, f3 = f2 * f; + vec2 w0 = (1.0 - 3.0 * f + 3.0 * f2 - f3) / 6.0, w1 = (4.0 - 6.0 * f2 + 3.0 * f3) / 6.0; + vec2 w2 = (1.0 + 3.0 * f + 3.0 * f2 - 3.0 * f3) / 6.0, w3 = f3 / 6.0; + vec2 g0 = w0 + w1, g1 = w2 + w3; + vec2 h0 = (i - 0.5 + w1 / g0) / uSize, h1 = (i + 1.5 + w3 / g1) / uSize; + return g0.y * (g0.x * textureLod(t, h0, 0.0) + g1.x * textureLod(t, vec2(h1.x, h0.y), 0.0)) + + g1.y * (g0.x * textureLod(t, vec2(h0.x, h1.y), 0.0) + g1.x * textureLod(t, h1, 0.0)); +} +vec4 cellAt(sampler2D t, ivec2 c) { return texelFetch(t, clamp(c, ivec2(0), ivec2(uSize) - 1), 0); } +int cls(vec4 c) { return int(c.a * 255.0 + 0.5); } +float isT(int n) { return (n == 1 || n == 4 || n == 6 || (n >= 7 && n <= 12)) ? 1.0 : 0.0; } +float isM(int n) { return n == 2 ? 1.0 : isT(n); } // matter: ground, built pieces or loose grains +float isF(int n) { return (n == 13 || n == 14) ? 1.0 : 0.0; } +float isBelt(int n) { return (n == 7 || n == 8) ? 1.0 : 0.0; } +float isN(int n, int k) { return n == k ? 1.0 : 0.0; } +// signed distance (in grains) to the outline of one block of belt: a band 3.7 grains tall; where +// the line of belts ends, the end is square with rounded corners. Negative inside. +float beltSD(vec2 lb, bool endL, bool endR) { + float x0 = endL ? 0.15 : -2.0, x1 = endR ? 3.85 : 6.0, r = 0.75; + vec2 c = vec2((x0 + x1) * 0.5, 2.0), hs = vec2((x1 - x0) * 0.5, 1.85); + vec2 q = abs(lb - c) - hs + r; + return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; +} +// The outline of the belt or filter in cell c. Belts and filters side by side are one belt, so +// the line only rounds off where the whole run ends. +float beltLineSD(ivec2 c, vec2 lb) { + int nr = cls(cellAt(tCol, c + ivec2(4, 0))), nl = cls(cellAt(tCol, c - ivec2(4, 0))); + return beltSD(lb, isBelt(nl) + isF(nl) < 0.5, isBelt(nr) + isF(nr) < 0.5); +} +// The rubber band of a belt or filter: 1 on a ridge. Ridges travel along the top run one way and +// back along the bottom. +float bandRidge(vec2 w, vec2 lb, float dir) { + float run = lb.y < 2.0 ? 1.0 : -1.0; + return step(0.72, fract((w.x - dir * run * uTime * 30.0) * 0.5)); +} +vec3 bandColor(vec2 lb, float rg) { return mix(vec3(0.2, 0.21, 0.24), vec3(0.34, 0.35, 0.39), rg) * (lb.y < 2.0 ? 1.0 : 0.72); } + +void main() { + float zp = uView.z, px = 1.0 / zp; + vec2 sp = vec2(gl_FragCoord.x, uView.w - gl_FragCoord.y); + vec2 q = sp * px + uView.xy; // position in grains, in the window of grains uploaded + vec2 w = q + uOrigin; // position in the world + vec2 cam = uOrigin + uView.xy; // world position of the screen's top-left corner + vec2 scr = q - uView.xy; // grains from the screen's top-left corner + float shc = uView.w * px; // screen height in grains + ivec2 c = ivec2(floor(q)); + vec2 qq = q - 0.5; ivec2 c0 = ivec2(floor(qq)); vec2 t = qq - floor(qq); + vec4 k0 = cellAt(tCol, c0), k1 = cellAt(tCol, c0 + ivec2(1, 0)), k2 = cellAt(tCol, c0 + ivec2(0, 1)), k3 = cellAt(tCol, c0 + ivec2(1, 1)); + int n0 = cls(k0), n1 = cls(k1), n2 = cls(k2), n3 = cls(k3); + vec4 own = cellAt(tCol, c); int nc = cls(own); + vec2 s = smoothstep(0.1, 0.9, t); + vec4 wq = vec4((1.0 - s.x) * (1.0 - s.y), s.x * (1.0 - s.y), (1.0 - s.x) * s.y, s.x * s.y); + + // fields, and everything that needs screen derivatives (worked out before any branching) + vec4 Fb = bsp(tField, q); + vec4 F = mix(lin(tField, q), Fb, 0.62); + // loose-grain occupancy from the four grains around (bilinear): ground and grains together make "matter" + vec4 wl = vec4((1.0 - t.x) * (1.0 - t.y), t.x * (1.0 - t.y), (1.0 - t.x) * t.y, t.x * t.y); + float pw = dot(wl, vec4(isN(n0, 2), isN(n1, 2), isN(n2, 2), isN(n3, 2))); + float M = clamp(F.r + pw, 0.0, 1.0); + float lf = clamp(mix(F.g, Fb.g, 0.6) + F.r + pw, 0.0, 1.0); + float fwT = max(fwidth(F.r), 1e-4), fwM = max(fwidth(M), 1e-4), fwA = max(fwidth(F.a), 1e-4), fwL = max(fwidth(lf), 1e-4); + vec2 gT = vec2(dFdx(mix(Fb.r, M, 0.5)), dFdy(mix(Fb.r, M, 0.5))), gL = vec2(dFdx(F.g), dFdy(F.g)); + + // ---- behind everything: sky with clouds and far hills, or the cave's back wall + vec4 back = lin(tBack, q); + vec3 C = back.rgb; + float sky = back.a; + if (sky > 0.01) { + vec3 S = C; + vec2 P0 = scr + cam * 0.08; + // clouds; while it rains they thicken and turn grey + float cl = smoothstep(0.56 - 0.3 * uRain, 0.78 - 0.2 * uRain, fbm(vec2(P0.x * 0.016 + uTime * 0.006, P0.y * 0.05))); + float hi = clamp((uSurf - 40.0 - w.y) / 50.0, 0.0, 1.0); + S = mix(S, mix(vec3(0.96, 0.95, 0.93), vec3(0.5, 0.52, 0.56), uRain), cl * (0.6 + 0.3 * uRain) * hi); + float k1 = 0.16; vec2 P1 = scr + cam * k1; + float base1 = shc * 0.5 + 8.0 + (uSurf - shc * 0.5) * k1; + float top1 = base1 - 36.0 * (0.25 + 0.9 * fbm(vec2(P1.x * 0.009, 1.7))); + float m1 = smoothstep(top1 - px * 1.5, top1 + px * 1.5, P1.y); + vec3 far = mix(vec3(0.50, 0.60, 0.72), S, 0.35) * (0.94 + 0.08 * fbm(P1 * 0.05)); + S = mix(S, far, m1); + float k2 = 0.38; vec2 P2 = scr + cam * k2; + float base2 = shc * 0.5 + 14.0 + (uSurf - shc * 0.5) * k2; + float top2 = base2 - 20.0 * (0.2 + 0.9 * fbm(vec2(P2.x * 0.016, 7.3))); + float m2 = smoothstep(top2 - px * 1.5, top2 + px * 1.5, P2.y); + vec3 near = vec3(0.36, 0.5, 0.47) * (0.92 + 0.14 * fbm(P2 * 0.08)); + near = mix(near, S, 0.42 - 0.2 * smoothstep(top2, top2 + 30.0, P2.y)); + S = mix(S, near, m2); + C = mix(C, S, sky); + } + // the back wall: rough rock, and darker close to the tunnel walls (ambient occlusion) + float notSky = 1.0 - sky; + C *= mix(1.0, 1.08 + 0.4 * fbm(w * 0.21), notSky); + float near = textureLod(tField, q / uSize, 2.2).r; + C *= 1.0 - 0.34 * smoothstep(0.12, 0.85, near) * (1.0 - 0.75 * sky); + + // ---- trees (behind the ground and everything on it) + if (F.a > 0.2) { + vec4 a0 = cellAt(tTree, c0), a1 = cellAt(tTree, c0 + ivec2(1, 0)), a2 = cellAt(tTree, c0 + ivec2(0, 1)), a3 = cellAt(tTree, c0 + ivec2(1, 1)); + vec4 m = vec4(step(0.4, a0.a), step(0.4, a1.a), step(0.4, a2.a), step(0.4, a3.a)) * wq; + float sm = m.x + m.y + m.z + m.w; + if (sm > 1e-4) { + vec3 tc = (a0.rgb * m.x + a1.rgb * m.y + a2.rgb * m.z + a3.rgb * m.w) / sm; + float leaf = (a0.a * m.x + a1.a * m.y + a2.a * m.z + a3.a * m.w) / sm; + float lv = smoothstep(0.6, 0.9, leaf); + tc *= mix(0.86 + 0.22 * vn(vec2(w.x * 1.4, w.y * 0.25)), 0.72 + 0.5 * fbm(w * 0.42), lv); + float d = (F.a - 0.36) / fwA; + tc *= mix(0.55, 1.0, smoothstep(0.4, 1.6, d)); + C = mix(C, tc, smoothstep(-0.7, 0.7, d)); + } + } + + // ---- smoke, steam and flame: soft, see-through puffs + if (Fb.b > 0.02) { + vec4 m = vec4(isN(n0, 5), isN(n1, 5), isN(n2, 5), isN(n3, 5)) * wq; + float sm = m.x + m.y + m.z + m.w; + vec3 gc = sm > 1e-4 ? (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm : own.rgb; + if (sm > 1e-4 || nc == 5) C = mix(C, gc, smoothstep(0.03, 0.55, Fb.b) * 0.92); + } + + // ---- liquids: smooth surfaces that fill right up against the ground, with a glossy top line + float anyL = isN(n0, 3) + isN(n1, 3) + isN(n2, 3) + isN(n3, 3); + if (anyL > 0.0 && lf > 0.3) { + vec4 m = vec4(isN(n0, 3), isN(n1, 3), isN(n2, 3), isN(n3, 3)) * (wq + 0.02); + float sm = m.x + m.y + m.z + m.w; + vec3 lc = (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm; + lc *= 0.94 + 0.1 * vn(w * 0.55 + vec2(uTime * 0.7, uTime * 0.25)); + float gl = length(gL); + float topSide = gl > 1e-5 ? clamp(gL.y / gl, 0.0, 1.0) : 0.0; + float band = 1.0 - smoothstep(0.5, 0.75, F.g); + lc += vec3(0.16, 0.18, 0.2) * topSide * band; + C = mix(C, lc, smoothstep(0.5 - fwL, 0.5 + fwL, lf)); + } + + // ---- the ground: rounded, outlined, with a lit top edge and a shaded underside + if (F.r > 0.3) { + vec4 m = vec4(isT(n0), isT(n1), isT(n2), isT(n3)) * wq; + float sm = m.x + m.y + m.z + m.w; + vec3 blur = bsp(tColL, q).rgb, tc = own.rgb; + float bd = 1e9; + if (m.x > 0.0) { vec3 e = k0.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k0.rgb; } } + if (m.y > 0.0) { vec3 e = k1.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k1.rgb; } } + if (m.z > 0.0) { vec3 e = k2.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k2.rgb; } } + if (m.w > 0.0) { vec3 e = k3.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k3.rgb; } } + if (sm < 1e-4) tc = own.rgb; + tc *= 0.84 + 0.3 * fbm(w * 0.28); + tc *= 0.95 + 0.1 * vn(w * 1.6); + float gl = length(gT); + vec2 nrm = gl > 1e-6 ? -gT / gl : vec2(0.0); + float band = 1.0 - smoothstep(0.5, 0.9, M); + float lit = dot(nrm, normalize(vec2(-0.5, -1.0))); + tc *= 1.0 + 0.42 * lit * band; + // the outline follows where ground and grains meet open space, never where rock meets gravel + float dm = (M - 0.5) / fwM; + tc *= mix(0.4, 1.0, smoothstep(0.5, 1.4 + zp * 0.08, dm)); + C = mix(C, tc, smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT)); + } + + // ---- loose grains: little round pebbles; a pocket closed in by four grains is filled + int np = int(isN(n0, 2) + isN(n1, 2) + isN(n2, 2) + isN(n3, 2)); + bool closed = isM(n0) + isM(n1) + isM(n2) + isM(n3) > 3.5; + if (np > 0) { + float best = 1e9; vec3 gc = vec3(0.0); vec2 gd = vec2(0.0); float gr = 0.6; + for (int k = 0; k < 4; k++) { + ivec2 o4 = ivec2(k & 1, k >> 1); + int nk = k == 0 ? n0 : k == 1 ? n1 : k == 2 ? n2 : n3; + if (nk != 2) continue; + vec4 ck = k == 0 ? k0 : k == 1 ? k1 : k == 2 ? k2 : k3; + vec2 ce = vec2(c0 + o4) + 0.5; + float r = 0.6 + 0.1 * (h21(ce + uOrigin) - 0.5); + vec2 dv = (q - ce) / r; + float dd = length(dv); + if (dd < best) { best = dd; gc = ck.rgb; gd = dv; gr = r; } + } + float cover = np == 4 ? 1.0 : 1.0 - smoothstep(1.0 - px / gr, 1.0 + px / gr, best); + if (closed && F.r < 0.5) cover = 1.0; + if (cover > 0.0) { + float r2 = min(1.0, dot(gd, gd)); + vec3 n3 = normalize(vec3(gd * 0.85, sqrt(1.0 - r2) + 0.25)); + float sh = np == 4 ? 0.9 + 0.08 * vn(w * 0.9) : 0.8 + 0.3 * max(0.0, dot(n3, normalize(vec3(-0.45, -0.8, 0.6)))); + vec3 pc = (np == 4 ? (k0.rgb * wq.x + k1.rgb * wq.y + k2.rgb * wq.z + k3.rgb * wq.w) : gc) * sh; + if (!closed) pc *= mix(1.0, 0.72, smoothstep(0.7, 1.0, best)); + C = mix(C, pc, cover); + } + } + + // ---- built pieces: crisp, and wall blocks drawn as bevelled tiles with mortar lines + if (nc == 4) C = own.rgb; + else if (nc == 7 || nc == 8) { + // belt: a rubber band round two copper rollers per block; the top run moves the load + float dir = nc == 8 ? 1.0 : -1.0; + vec2 lb = fract(w * 0.25) * 4.0; + // outline: square ends with rounded corners where the line of belts (and filters) ends + float sd = beltLineSD(c, lb); + float inside = 1.0 - smoothstep(-px, px, sd); + if (inside > 0.0) { + float edge = -sd; + vec3 bc; + if (edge < 0.8) { + bc = bandColor(lb, bandRidge(w, lb, dir)); + bc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge)); + } else { + vec2 rc = vec2(lb.x < 2.0 ? 1.0 : 3.0, 2.0), dv = lb - rc; + float rd = length(dv); + bc = vec3(0.15, 0.16, 0.18); + if (rd < 0.95) { + float ang = atan(dv.y, dv.x) - dir * uTime * 30.0 / 0.95; + float spoke = smoothstep(0.55, 0.85, cos(ang * 3.0)); + bc = mix(vec3(0.5, 0.36, 0.25), vec3(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd)); + bc *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd)); + bc *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd)); + } + } + C = mix(C, bc, inside); + } + } + else if (nc >= 9 && nc <= 12) { + // screw tube: a copper pipe; what is inside shows through, and the screw's flights turn + bool vert = nc == 9 || nc == 11; + float sp2 = nc == 9 ? -1.0 : nc == 10 ? 1.0 : nc == 11 ? 1.0 : -1.0; + float a = (vert ? fract(w.x * 0.25) : fract(w.y * 0.25)) * 4.0, along = vert ? w.y : w.x; + float wall = 0.62; + vec3 tc; + if (a < wall || a > 4.0 - wall) { + float u2 = a < wall ? a / wall : (4.0 - a) / wall; // 0 at the outside, 1 at the bore + float cyl = sin(u2 * 1.5708); + tc = vec3(0.62, 0.41, 0.27) * (0.6 + 0.55 * cyl); + if (a < wall) tc *= 1.08; + float ring = 1.0 - smoothstep(0.0, 0.3, min(fract(along * 0.25), 1.0 - fract(along * 0.25)) * 4.0); + tc *= 1.0 - 0.3 * ring; + tc *= mix(0.5, 1.0, smoothstep(0.0, max(px * 1.3, 0.1), min(a, 4.0 - a))); + } else { + bool full = own.r + own.g + own.b > 0.0; + float fl = fract((along + a * 0.9 - sp2 * uTime * 14.0) * 0.5); + float flight = smoothstep(0.82, 0.92, fl) * (1.0 - smoothstep(0.92, 1.0, fl)); + vec3 bore = vec3(0.11, 0.1, 0.11) * (0.8 + 0.4 * (1.0 - abs(a - 2.0) / 1.4)); + tc = full ? own.rgb * (0.9 + 0.1 * vn(w * 1.3)) : bore; + tc = mix(tc, vec3(0.6, 0.48, 0.38), flight * (full ? 0.35 : 0.8)); + tc *= 1.0 - 0.25 * smoothstep(0.9, 1.3, abs(a - 2.0)); + } + C = tc; + } + else if (nc == 6) { + vec2 lb = fract(w * 0.25) * 4.0, rb = 4.0 - lb; + float e = min(min(lb.x, lb.y), min(rb.x, rb.y)); + vec3 tc = own.rgb * (0.92 + 0.14 * fbm(w * 0.55)); + float hl = max(1.0 - smoothstep(0.0, 0.8, lb.x), 1.0 - smoothstep(0.0, 0.8, lb.y)); + float shd = max(1.0 - smoothstep(0.0, 0.8, rb.x), 1.0 - smoothstep(0.0, 0.8, rb.y)); + tc *= 1.0 + 0.2 * hl - 0.26 * shd; + tc *= mix(0.5, 1.0, smoothstep(px * 0.5, px * 0.5 + max(px * 1.2, 0.14), e)); + C = tc; + } + + else if (nc == 13 || nc == 14) { + // filter: a belt whose middle is a see-through steel mesh. It has the same band as a belt, so a + // row of belts and filters reads as one belt, rounded only where the whole line ends. The + // band is perforated; its colour tag sits on the top run for what rides along, on the + // bottom run for what drops through. + float dir = nc == 14 ? 1.0 : -1.0; + vec2 lb = fract(w * 0.25) * 4.0; + vec4 tag = cellAt(tTree, c); + bool carry = tag.a > 0.16, tinted = tag.r + tag.g + tag.b > 0.0; + float sd = beltLineSD(c, lb); + float inside = 1.0 - smoothstep(-px, px, sd); + if (inside > 0.0) { + float edge = -sd; + vec3 steel = vec3(0.42, 0.44, 0.5); + // through the holes: whatever is behind (water, sky, rock), or what is passing through + vec3 behind = own.r + own.g + own.b > 0.0 ? own.rgb : C * 0.8; + vec2 cg = abs(fract(w) - 0.5); + float bw2 = max(px * 0.9, 0.09); + float hole = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y)); + vec3 fc; + if (edge < 0.8) { + float rg = bandRidge(w, lb, dir); + vec3 band = bandColor(lb, rg); + if (tinted && (carry == (lb.y < 2.0))) band = mix(band, tag.rgb * (lb.y < 2.0 ? 1.0 : 0.8), 0.75 * rg); + fc = mix(band, behind * 0.85, hole * (1.0 - rg) * 0.75); + fc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge)); + } else { + fc = mix(steel * 0.78, behind, hole); + fc *= mix(0.7, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge - 0.8)); + } + C = mix(C, fc, inside); + } + } + + // ---- light, then the glow of hot things on top + if (uLightMap.w > 0.5) C *= textureLod(tLight, (q - uLightMap.xy) / uLightMap.z / uLightSize, 0.0).rgb; + if (uGlowK > 0.0) { + vec2 gu = q / (2.0 * uGlowSize), d = 1.0 / uGlowSize; + vec3 g = textureLod(tGlow, gu, 0.0).rgb * 0.25; + g += (textureLod(tGlow, gu + vec2(d.x, 0), 0.0).rgb + textureLod(tGlow, gu - vec2(d.x, 0), 0.0).rgb + textureLod(tGlow, gu + vec2(0, d.y), 0.0).rgb + textureLod(tGlow, gu - vec2(0, d.y), 0.0).rgb) * 0.125; + g += (textureLod(tGlow, gu + d, 0.0).rgb + textureLod(tGlow, gu - d, 0.0).rgb + textureLod(tGlow, gu + vec2(d.x, -d.y), 0.0).rgb + textureLod(tGlow, gu + vec2(-d.x, d.y), 0.0).rgb) * 0.0625; + C += g * uGlowK; + } + o = vec4(C, 1.0); +}`; + diff --git a/src/render/smooth.js b/src/render/smooth.js index 737476e..d6702aa 100644 --- a/src/render/smooth.js +++ b/src/render/smooth.js @@ -1,337 +1,6 @@ -/* ===================================================================== - Smooth look: a WebGL2 pass that turns the per-grain buffers drawWorld - fills (colour + class, colour behind, trees, occupancy fields, light, - glow) into rounded, outlined, softly lit shapes at screen resolution. - The 2D canvas then draws this image as its world layer, and every - overlay (machines, bot, previews, labels) goes on top as before. - ===================================================================== */ - +// Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution. const SM = { tried: false, ok: false, why: '', gl: null, canvas: null, prog: null, tex: {}, u: {}, t0: performance.now(), white: null, surf: -1, scale: 1, ms: 0, n: 0 }; -const SM_VS = `#version 300 es -void main() { - vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); - gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); -}`; - -const SM_FS = `#version 300 es -precision highp float; -precision highp int; -uniform sampler2D tCol, tColL, tBack, tTree, tField, tLight, tGlow; -uniform vec2 uSize, uOrigin, uLightSize, uGlowSize; -uniform vec4 uView, uLightMap; -uniform float uGlowK, uTime, uSurf, uRain; -out vec4 o; - -float h21(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); } -float vn(vec2 p) { - vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f); - return mix(mix(h21(i), h21(i + vec2(1, 0)), f.x), mix(h21(i + vec2(0, 1)), h21(i + vec2(1, 1)), f.x), f.y); -} -float fbm(vec2 p) { float a = 0.5, s = 0.0; for (int k = 0; k < 4; k++) { s += a * vn(p); p = p * 2.03 + 17.1; a *= 0.5; } return s / 0.9375; } - -vec4 lin(sampler2D t, vec2 q) { return textureLod(t, q / uSize, 0.0); } -// cubic B-spline from four bilinear taps: rounds a field of 0s and 1s into smooth shapes -vec4 bsp(sampler2D t, vec2 q) { - vec2 tc = q - 0.5, i = floor(tc), f = tc - i, f2 = f * f, f3 = f2 * f; - vec2 w0 = (1.0 - 3.0 * f + 3.0 * f2 - f3) / 6.0, w1 = (4.0 - 6.0 * f2 + 3.0 * f3) / 6.0; - vec2 w2 = (1.0 + 3.0 * f + 3.0 * f2 - 3.0 * f3) / 6.0, w3 = f3 / 6.0; - vec2 g0 = w0 + w1, g1 = w2 + w3; - vec2 h0 = (i - 0.5 + w1 / g0) / uSize, h1 = (i + 1.5 + w3 / g1) / uSize; - return g0.y * (g0.x * textureLod(t, h0, 0.0) + g1.x * textureLod(t, vec2(h1.x, h0.y), 0.0)) - + g1.y * (g0.x * textureLod(t, vec2(h0.x, h1.y), 0.0) + g1.x * textureLod(t, h1, 0.0)); -} -vec4 cellAt(sampler2D t, ivec2 c) { return texelFetch(t, clamp(c, ivec2(0), ivec2(uSize) - 1), 0); } -int cls(vec4 c) { return int(c.a * 255.0 + 0.5); } -float isT(int n) { return (n == 1 || n == 4 || n == 6 || (n >= 7 && n <= 12)) ? 1.0 : 0.0; } -float isM(int n) { return n == 2 ? 1.0 : isT(n); } // matter: ground, built pieces or loose grains -float isF(int n) { return (n == 13 || n == 14) ? 1.0 : 0.0; } -float isBelt(int n) { return (n == 7 || n == 8) ? 1.0 : 0.0; } -float isN(int n, int k) { return n == k ? 1.0 : 0.0; } -// signed distance (in grains) to the outline of one block of belt: a band 3.7 grains tall; where -// the line of belts ends, the end is square with rounded corners. Negative inside. -float beltSD(vec2 lb, bool endL, bool endR) { - float x0 = endL ? 0.15 : -2.0, x1 = endR ? 3.85 : 6.0, r = 0.75; - vec2 c = vec2((x0 + x1) * 0.5, 2.0), hs = vec2((x1 - x0) * 0.5, 1.85); - vec2 q = abs(lb - c) - hs + r; - return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; -} -// The outline of the belt or filter in cell c. Belts and filters side by side are one belt, so -// the line only rounds off where the whole run ends. -float beltLineSD(ivec2 c, vec2 lb) { - int nr = cls(cellAt(tCol, c + ivec2(4, 0))), nl = cls(cellAt(tCol, c - ivec2(4, 0))); - return beltSD(lb, isBelt(nl) + isF(nl) < 0.5, isBelt(nr) + isF(nr) < 0.5); -} -// The rubber band of a belt or filter: 1 on a ridge. Ridges travel along the top run one way and -// back along the bottom. -float bandRidge(vec2 w, vec2 lb, float dir) { - float run = lb.y < 2.0 ? 1.0 : -1.0; - return step(0.72, fract((w.x - dir * run * uTime * 30.0) * 0.5)); -} -vec3 bandColor(vec2 lb, float rg) { return mix(vec3(0.2, 0.21, 0.24), vec3(0.34, 0.35, 0.39), rg) * (lb.y < 2.0 ? 1.0 : 0.72); } - -void main() { - float zp = uView.z, px = 1.0 / zp; - vec2 sp = vec2(gl_FragCoord.x, uView.w - gl_FragCoord.y); - vec2 q = sp * px + uView.xy; // position in grains, in the window of grains uploaded - vec2 w = q + uOrigin; // position in the world - vec2 cam = uOrigin + uView.xy; // world position of the screen's top-left corner - vec2 scr = q - uView.xy; // grains from the screen's top-left corner - float shc = uView.w * px; // screen height in grains - ivec2 c = ivec2(floor(q)); - vec2 qq = q - 0.5; ivec2 c0 = ivec2(floor(qq)); vec2 t = qq - floor(qq); - vec4 k0 = cellAt(tCol, c0), k1 = cellAt(tCol, c0 + ivec2(1, 0)), k2 = cellAt(tCol, c0 + ivec2(0, 1)), k3 = cellAt(tCol, c0 + ivec2(1, 1)); - int n0 = cls(k0), n1 = cls(k1), n2 = cls(k2), n3 = cls(k3); - vec4 own = cellAt(tCol, c); int nc = cls(own); - vec2 s = smoothstep(0.1, 0.9, t); - vec4 wq = vec4((1.0 - s.x) * (1.0 - s.y), s.x * (1.0 - s.y), (1.0 - s.x) * s.y, s.x * s.y); - - // fields, and everything that needs screen derivatives (worked out before any branching) - vec4 Fb = bsp(tField, q); - vec4 F = mix(lin(tField, q), Fb, 0.62); - // loose-grain occupancy from the four grains around (bilinear): ground and grains together make "matter" - vec4 wl = vec4((1.0 - t.x) * (1.0 - t.y), t.x * (1.0 - t.y), (1.0 - t.x) * t.y, t.x * t.y); - float pw = dot(wl, vec4(isN(n0, 2), isN(n1, 2), isN(n2, 2), isN(n3, 2))); - float M = clamp(F.r + pw, 0.0, 1.0); - float lf = clamp(mix(F.g, Fb.g, 0.6) + F.r + pw, 0.0, 1.0); - float fwT = max(fwidth(F.r), 1e-4), fwM = max(fwidth(M), 1e-4), fwA = max(fwidth(F.a), 1e-4), fwL = max(fwidth(lf), 1e-4); - vec2 gT = vec2(dFdx(mix(Fb.r, M, 0.5)), dFdy(mix(Fb.r, M, 0.5))), gL = vec2(dFdx(F.g), dFdy(F.g)); - - // ---- behind everything: sky with clouds and far hills, or the cave's back wall - vec4 back = lin(tBack, q); - vec3 C = back.rgb; - float sky = back.a; - if (sky > 0.01) { - vec3 S = C; - vec2 P0 = scr + cam * 0.08; - // clouds; while it rains they thicken and turn grey - float cl = smoothstep(0.56 - 0.3 * uRain, 0.78 - 0.2 * uRain, fbm(vec2(P0.x * 0.016 + uTime * 0.006, P0.y * 0.05))); - float hi = clamp((uSurf - 40.0 - w.y) / 50.0, 0.0, 1.0); - S = mix(S, mix(vec3(0.96, 0.95, 0.93), vec3(0.5, 0.52, 0.56), uRain), cl * (0.6 + 0.3 * uRain) * hi); - float k1 = 0.16; vec2 P1 = scr + cam * k1; - float base1 = shc * 0.5 + 8.0 + (uSurf - shc * 0.5) * k1; - float top1 = base1 - 36.0 * (0.25 + 0.9 * fbm(vec2(P1.x * 0.009, 1.7))); - float m1 = smoothstep(top1 - px * 1.5, top1 + px * 1.5, P1.y); - vec3 far = mix(vec3(0.50, 0.60, 0.72), S, 0.35) * (0.94 + 0.08 * fbm(P1 * 0.05)); - S = mix(S, far, m1); - float k2 = 0.38; vec2 P2 = scr + cam * k2; - float base2 = shc * 0.5 + 14.0 + (uSurf - shc * 0.5) * k2; - float top2 = base2 - 20.0 * (0.2 + 0.9 * fbm(vec2(P2.x * 0.016, 7.3))); - float m2 = smoothstep(top2 - px * 1.5, top2 + px * 1.5, P2.y); - vec3 near = vec3(0.36, 0.5, 0.47) * (0.92 + 0.14 * fbm(P2 * 0.08)); - near = mix(near, S, 0.42 - 0.2 * smoothstep(top2, top2 + 30.0, P2.y)); - S = mix(S, near, m2); - C = mix(C, S, sky); - } - // the back wall: rough rock, and darker close to the tunnel walls (ambient occlusion) - float notSky = 1.0 - sky; - C *= mix(1.0, 1.08 + 0.4 * fbm(w * 0.21), notSky); - float near = textureLod(tField, q / uSize, 2.2).r; - C *= 1.0 - 0.34 * smoothstep(0.12, 0.85, near) * (1.0 - 0.75 * sky); - - // ---- trees (behind the ground and everything on it) - if (F.a > 0.2) { - vec4 a0 = cellAt(tTree, c0), a1 = cellAt(tTree, c0 + ivec2(1, 0)), a2 = cellAt(tTree, c0 + ivec2(0, 1)), a3 = cellAt(tTree, c0 + ivec2(1, 1)); - vec4 m = vec4(step(0.4, a0.a), step(0.4, a1.a), step(0.4, a2.a), step(0.4, a3.a)) * wq; - float sm = m.x + m.y + m.z + m.w; - if (sm > 1e-4) { - vec3 tc = (a0.rgb * m.x + a1.rgb * m.y + a2.rgb * m.z + a3.rgb * m.w) / sm; - float leaf = (a0.a * m.x + a1.a * m.y + a2.a * m.z + a3.a * m.w) / sm; - float lv = smoothstep(0.6, 0.9, leaf); - tc *= mix(0.86 + 0.22 * vn(vec2(w.x * 1.4, w.y * 0.25)), 0.72 + 0.5 * fbm(w * 0.42), lv); - float d = (F.a - 0.36) / fwA; - tc *= mix(0.55, 1.0, smoothstep(0.4, 1.6, d)); - C = mix(C, tc, smoothstep(-0.7, 0.7, d)); - } - } - - // ---- smoke, steam and flame: soft, see-through puffs - if (Fb.b > 0.02) { - vec4 m = vec4(isN(n0, 5), isN(n1, 5), isN(n2, 5), isN(n3, 5)) * wq; - float sm = m.x + m.y + m.z + m.w; - vec3 gc = sm > 1e-4 ? (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm : own.rgb; - if (sm > 1e-4 || nc == 5) C = mix(C, gc, smoothstep(0.03, 0.55, Fb.b) * 0.92); - } - - // ---- liquids: smooth surfaces that fill right up against the ground, with a glossy top line - float anyL = isN(n0, 3) + isN(n1, 3) + isN(n2, 3) + isN(n3, 3); - if (anyL > 0.0 && lf > 0.3) { - vec4 m = vec4(isN(n0, 3), isN(n1, 3), isN(n2, 3), isN(n3, 3)) * (wq + 0.02); - float sm = m.x + m.y + m.z + m.w; - vec3 lc = (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm; - lc *= 0.94 + 0.1 * vn(w * 0.55 + vec2(uTime * 0.7, uTime * 0.25)); - float gl = length(gL); - float topSide = gl > 1e-5 ? clamp(gL.y / gl, 0.0, 1.0) : 0.0; - float band = 1.0 - smoothstep(0.5, 0.75, F.g); - lc += vec3(0.16, 0.18, 0.2) * topSide * band; - C = mix(C, lc, smoothstep(0.5 - fwL, 0.5 + fwL, lf)); - } - - // ---- the ground: rounded, outlined, with a lit top edge and a shaded underside - if (F.r > 0.3) { - vec4 m = vec4(isT(n0), isT(n1), isT(n2), isT(n3)) * wq; - float sm = m.x + m.y + m.z + m.w; - vec3 blur = bsp(tColL, q).rgb, tc = own.rgb; - float bd = 1e9; - if (m.x > 0.0) { vec3 e = k0.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k0.rgb; } } - if (m.y > 0.0) { vec3 e = k1.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k1.rgb; } } - if (m.z > 0.0) { vec3 e = k2.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k2.rgb; } } - if (m.w > 0.0) { vec3 e = k3.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k3.rgb; } } - if (sm < 1e-4) tc = own.rgb; - tc *= 0.84 + 0.3 * fbm(w * 0.28); - tc *= 0.95 + 0.1 * vn(w * 1.6); - float gl = length(gT); - vec2 nrm = gl > 1e-6 ? -gT / gl : vec2(0.0); - float band = 1.0 - smoothstep(0.5, 0.9, M); - float lit = dot(nrm, normalize(vec2(-0.5, -1.0))); - tc *= 1.0 + 0.42 * lit * band; - // the outline follows where ground and grains meet open space, never where rock meets gravel - float dm = (M - 0.5) / fwM; - tc *= mix(0.4, 1.0, smoothstep(0.5, 1.4 + zp * 0.08, dm)); - C = mix(C, tc, smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT)); - } - - // ---- loose grains: little round pebbles; a pocket closed in by four grains is filled - int np = int(isN(n0, 2) + isN(n1, 2) + isN(n2, 2) + isN(n3, 2)); - bool closed = isM(n0) + isM(n1) + isM(n2) + isM(n3) > 3.5; - if (np > 0) { - float best = 1e9; vec3 gc = vec3(0.0); vec2 gd = vec2(0.0); float gr = 0.6; - for (int k = 0; k < 4; k++) { - ivec2 o4 = ivec2(k & 1, k >> 1); - int nk = k == 0 ? n0 : k == 1 ? n1 : k == 2 ? n2 : n3; - if (nk != 2) continue; - vec4 ck = k == 0 ? k0 : k == 1 ? k1 : k == 2 ? k2 : k3; - vec2 ce = vec2(c0 + o4) + 0.5; - float r = 0.6 + 0.1 * (h21(ce + uOrigin) - 0.5); - vec2 dv = (q - ce) / r; - float dd = length(dv); - if (dd < best) { best = dd; gc = ck.rgb; gd = dv; gr = r; } - } - float cover = np == 4 ? 1.0 : 1.0 - smoothstep(1.0 - px / gr, 1.0 + px / gr, best); - if (closed && F.r < 0.5) cover = 1.0; - if (cover > 0.0) { - float r2 = min(1.0, dot(gd, gd)); - vec3 n3 = normalize(vec3(gd * 0.85, sqrt(1.0 - r2) + 0.25)); - float sh = np == 4 ? 0.9 + 0.08 * vn(w * 0.9) : 0.8 + 0.3 * max(0.0, dot(n3, normalize(vec3(-0.45, -0.8, 0.6)))); - vec3 pc = (np == 4 ? (k0.rgb * wq.x + k1.rgb * wq.y + k2.rgb * wq.z + k3.rgb * wq.w) : gc) * sh; - if (!closed) pc *= mix(1.0, 0.72, smoothstep(0.7, 1.0, best)); - C = mix(C, pc, cover); - } - } - - // ---- built pieces: crisp, and wall blocks drawn as bevelled tiles with mortar lines - if (nc == 4) C = own.rgb; - else if (nc == 7 || nc == 8) { - // belt: a rubber band round two copper rollers per block; the top run moves the load - float dir = nc == 8 ? 1.0 : -1.0; - vec2 lb = fract(w * 0.25) * 4.0; - // outline: square ends with rounded corners where the line of belts (and filters) ends - float sd = beltLineSD(c, lb); - float inside = 1.0 - smoothstep(-px, px, sd); - if (inside > 0.0) { - float edge = -sd; - vec3 bc; - if (edge < 0.8) { - bc = bandColor(lb, bandRidge(w, lb, dir)); - bc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge)); - } else { - vec2 rc = vec2(lb.x < 2.0 ? 1.0 : 3.0, 2.0), dv = lb - rc; - float rd = length(dv); - bc = vec3(0.15, 0.16, 0.18); - if (rd < 0.95) { - float ang = atan(dv.y, dv.x) - dir * uTime * 30.0 / 0.95; - float spoke = smoothstep(0.55, 0.85, cos(ang * 3.0)); - bc = mix(vec3(0.5, 0.36, 0.25), vec3(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd)); - bc *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd)); - bc *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd)); - } - } - C = mix(C, bc, inside); - } - } - else if (nc >= 9 && nc <= 12) { - // screw tube: a copper pipe; what is inside shows through, and the screw's flights turn - bool vert = nc == 9 || nc == 11; - float sp2 = nc == 9 ? -1.0 : nc == 10 ? 1.0 : nc == 11 ? 1.0 : -1.0; - float a = (vert ? fract(w.x * 0.25) : fract(w.y * 0.25)) * 4.0, along = vert ? w.y : w.x; - float wall = 0.62; - vec3 tc; - if (a < wall || a > 4.0 - wall) { - float u2 = a < wall ? a / wall : (4.0 - a) / wall; // 0 at the outside, 1 at the bore - float cyl = sin(u2 * 1.5708); - tc = vec3(0.62, 0.41, 0.27) * (0.6 + 0.55 * cyl); - if (a < wall) tc *= 1.08; - float ring = 1.0 - smoothstep(0.0, 0.3, min(fract(along * 0.25), 1.0 - fract(along * 0.25)) * 4.0); - tc *= 1.0 - 0.3 * ring; - tc *= mix(0.5, 1.0, smoothstep(0.0, max(px * 1.3, 0.1), min(a, 4.0 - a))); - } else { - bool full = own.r + own.g + own.b > 0.0; - float fl = fract((along + a * 0.9 - sp2 * uTime * 14.0) * 0.5); - float flight = smoothstep(0.82, 0.92, fl) * (1.0 - smoothstep(0.92, 1.0, fl)); - vec3 bore = vec3(0.11, 0.1, 0.11) * (0.8 + 0.4 * (1.0 - abs(a - 2.0) / 1.4)); - tc = full ? own.rgb * (0.9 + 0.1 * vn(w * 1.3)) : bore; - tc = mix(tc, vec3(0.6, 0.48, 0.38), flight * (full ? 0.35 : 0.8)); - tc *= 1.0 - 0.25 * smoothstep(0.9, 1.3, abs(a - 2.0)); - } - C = tc; - } - else if (nc == 6) { - vec2 lb = fract(w * 0.25) * 4.0, rb = 4.0 - lb; - float e = min(min(lb.x, lb.y), min(rb.x, rb.y)); - vec3 tc = own.rgb * (0.92 + 0.14 * fbm(w * 0.55)); - float hl = max(1.0 - smoothstep(0.0, 0.8, lb.x), 1.0 - smoothstep(0.0, 0.8, lb.y)); - float shd = max(1.0 - smoothstep(0.0, 0.8, rb.x), 1.0 - smoothstep(0.0, 0.8, rb.y)); - tc *= 1.0 + 0.2 * hl - 0.26 * shd; - tc *= mix(0.5, 1.0, smoothstep(px * 0.5, px * 0.5 + max(px * 1.2, 0.14), e)); - C = tc; - } - - else if (nc == 13 || nc == 14) { - // filter: a belt whose middle is a see-through steel mesh. It has the same band as a belt, so a - // row of belts and filters reads as one belt, rounded only where the whole line ends. The - // band is perforated; its colour tag sits on the top run for what rides along, on the - // bottom run for what drops through. - float dir = nc == 14 ? 1.0 : -1.0; - vec2 lb = fract(w * 0.25) * 4.0; - vec4 tag = cellAt(tTree, c); - bool carry = tag.a > 0.16, tinted = tag.r + tag.g + tag.b > 0.0; - float sd = beltLineSD(c, lb); - float inside = 1.0 - smoothstep(-px, px, sd); - if (inside > 0.0) { - float edge = -sd; - vec3 steel = vec3(0.42, 0.44, 0.5); - // through the holes: whatever is behind (water, sky, rock), or what is passing through - vec3 behind = own.r + own.g + own.b > 0.0 ? own.rgb : C * 0.8; - vec2 cg = abs(fract(w) - 0.5); - float bw2 = max(px * 0.9, 0.09); - float hole = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y)); - vec3 fc; - if (edge < 0.8) { - float rg = bandRidge(w, lb, dir); - vec3 band = bandColor(lb, rg); - if (tinted && (carry == (lb.y < 2.0))) band = mix(band, tag.rgb * (lb.y < 2.0 ? 1.0 : 0.8), 0.75 * rg); - fc = mix(band, behind * 0.85, hole * (1.0 - rg) * 0.75); - fc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge)); - } else { - fc = mix(steel * 0.78, behind, hole); - fc *= mix(0.7, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge - 0.8)); - } - C = mix(C, fc, inside); - } - } - - // ---- light, then the glow of hot things on top - if (uLightMap.w > 0.5) C *= textureLod(tLight, (q - uLightMap.xy) / uLightMap.z / uLightSize, 0.0).rgb; - if (uGlowK > 0.0) { - vec2 gu = q / (2.0 * uGlowSize), d = 1.0 / uGlowSize; - vec3 g = textureLod(tGlow, gu, 0.0).rgb * 0.25; - g += (textureLod(tGlow, gu + vec2(d.x, 0), 0.0).rgb + textureLod(tGlow, gu - vec2(d.x, 0), 0.0).rgb + textureLod(tGlow, gu + vec2(0, d.y), 0.0).rgb + textureLod(tGlow, gu - vec2(0, d.y), 0.0).rgb) * 0.125; - g += (textureLod(tGlow, gu + d, 0.0).rgb + textureLod(tGlow, gu - d, 0.0).rgb + textureLod(tGlow, gu + vec2(d.x, -d.y), 0.0).rgb + textureLod(tGlow, gu + vec2(-d.x, d.y), 0.0).rgb) * 0.0625; - C += g * uGlowK; - } - o = vec4(C, 1.0); -}`; - function smCompile(gl, type, src) { const sh = gl.createShader(type); gl.shaderSource(sh, src); gl.compileShader(sh); @@ -391,7 +60,7 @@ function smoothDraw(lighting, glowOn) { // the GPU pass can run below full resolution on a slow graphics card; it is scaled up smoothly const gl = SM.gl, cv = SM.canvas, sc = SM.scale, cw = Math.max(1, Math.round(R.canvas.width * sc)), ch = Math.max(1, Math.round(R.canvas.height * sc)), u = SM.u; if (cv.width !== cw || cv.height !== ch) { cv.width = cw; cv.height = ch; } - if (SM.surf < 0 || SM.surfSeed !== worldSeed || SM.surfW !== W) { let s = 0; for (let x = 0; x < W; x++) s += surfY[x]; SM.surf = s / W; SM.surfW = W; SM.surfSeed = worldSeed; } + if (SM.surf < 0 || SM.surfSeed !== worldSeed || SM.surfW !== W) { SM.surf = meanSurface(); SM.surfW = W; SM.surfSeed = worldSeed; } gl.viewport(0, 0, cw, ch); gl.useProgram(SM.prog); const bw = R.bw, bh = R.bh; diff --git a/src/render/world.js b/src/render/world.js index eba9b20..1a8052c 100644 --- a/src/render/world.js +++ b/src/render/world.js @@ -12,7 +12,7 @@ function drawWorld() { R.rain = (R.rain || 0) * 0.98 + rainAt(Math.max(0, Math.min(W - 1, Math.floor(R.camX + bw / 2)))) * 0.02; const rk = R.rain; // Smooth look: the colours below are still worked out per grain, but each grain also gets a - // class (alpha byte) and the GPU draws rounded shapes from them (see smooth.js) + // class (alpha byte) and the GPU draws rounded shapes from them (see smooth-shaders.js) const sm = R.smoothOn = SET.look === 'smooth' && !R.lens && typeof smoothAvailable === 'function' && smoothAvailable(); const bpx = R.backPx, tpx = R.treePx, fpx = R.fieldPx, mmask = R.moldMask; if (sm) { diff --git a/src/sim/blocks.js b/src/sim/blocks.js index acd8597..74db3d8 100644 --- a/src/sim/blocks.js +++ b/src/sim/blocks.js @@ -71,6 +71,15 @@ function blockIntegrity(b) { void v; return n; } +// One cell of a wall or mold being taken back: metal is counted toward bars (returns 1), other +// walls give their rubble back. +function removeWallCell(i, wall) { + const m = mat[i]; + if (BAR_OF[m] && PH[wall] === PH_ITEM) { setCell(i, EMPTY, temp[i], 0); return 1; } + if (YLD[m] && bagAdd(YLD[m], 1, false) === 0) { setCell(i, YLD[m], temp[i], 0); droppedLast++; } // bag full: it crumbles in place + else setCell(i, EMPTY, temp[i], 0); + return 0; +} function removeBlock(bx, by) { const b = by * BW + bx, code = blockP[b], v = blockV[b]; if (!code) return 0; @@ -82,9 +91,7 @@ function removeBlock(bx, by) { const m = mat[i], p = part[i]; if (code === BP_WALL) { if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m] && m !== SPRING) { - if (BAR_OF[m] && PH[v] === PH_ITEM) { metal++; setCell(i, EMPTY, temp[i], 0); } - else if (YLD[m] && bagAdd(YLD[m], 1, false) === 0) { setCell(i, YLD[m], temp[i], 0); droppedLast++; } // bag full: it crumbles in place - else setCell(i, EMPTY, temp[i], 0); + metal += removeWallCell(i, v); n++; } } else if (isPipeCode(code)) { diff --git a/src/sim/items.js b/src/sim/items.js index add7d55..65d7864 100644 --- a/src/sim/items.js +++ b/src/sim/items.js @@ -62,13 +62,19 @@ function entTubeMove(e, x, y, tubeOnly = false) { for (let xx = Math.min(e.ix, x); xx <= Math.max(e.ix, x); xx++) if (!entTubeFree(xx, yy)) return false; e.x = e.ix = x; e.y = e.iy = y; e.vx = e.vy = 0; return true; } +function entTubeLane(y, iy) { return Math.max(y & ~3, Math.min((y & ~3) + 2, iy)); } +function entTubeEdge(e, dx, dy, k) { + const x = e.ix + (dx > 0 ? BAR_W : dx < 0 ? -1 : k), y = e.iy + (dy > 0 ? BAR_H : dy < 0 ? -1 : k); + return x < 0 || y < 0 || x >= W || y >= H ? -1 : y * W + x; +} function entTubeEnter(e, dx, dy) { for (let k = 0; k < (dx ? BAR_H : BAR_W); k++) { - const xx = e.ix + (dx > 0 ? BAR_W : dx < 0 ? -1 : k), yy = e.iy + (dy > 0 ? BAR_H : dy < 0 ? -1 : k); - if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue; - const p = part[yy * W + xx]; if (p < P_TU || p > P_TR || (!dx && TDX[p]) || dx * TDX[p] + dy * TDY[p] < 0) continue; + const i = entTubeEdge(e, dx, dy, k); + if (i < 0) continue; + const xx = i % W, yy = (i / W) | 0; + const p = part[i]; if (p < P_TU || p > P_TR || (!dx && TDX[p]) || dx * TDX[p] + dy * TDY[p] < 0) continue; // Vertical screws require the full width; horizontal screws use the nearest two-row lane. - const x = (xx & ~3), y = Math.max(yy & ~3, Math.min((yy & ~3) + 2, e.iy)); + const x = (xx & ~3), y = entTubeLane(yy, e.iy); if (entTubeMove(e, x, TDX[p] || dy ? y : e.iy, true)) return true; } return false; @@ -80,14 +86,15 @@ function entTubeTick(e, p) { const dx = TDX[p], dy = TDY[p]; let x = e.ix + dx, y = e.iy + dy, next = 0, ax = 0, ay = 0; for (let k = 0; k < (dx ? BAR_H : BAR_W); k++) { - const xx = e.ix + (dx > 0 ? BAR_W : dx < 0 ? -1 : k), yy = e.iy + (dy > 0 ? BAR_H : dy < 0 ? -1 : k); - if (xx < 0 || yy < 0 || xx >= W || yy >= H) return; - const q = part[yy * W + xx]; + const i = entTubeEdge(e, dx, dy, k); + if (i < 0) return; + const xx = i % W, yy = (i / W) | 0; + const q = part[i]; if (q >= P_TU && q <= P_TR && q !== p) { next = q; ax = xx; ay = yy; break; } } if (next) { if (TDX[next] === -dx && TDY[next] === -dy) return; - x = ax & ~3; y = Math.max(ay & ~3, Math.min((ay & ~3) + 2, e.iy)); + x = ax & ~3; y = entTubeLane(ay, e.iy); if (entTubeMove(e, x, y, true)) stats.moved++; return; } diff --git a/src/sim/material-tables.js b/src/sim/material-tables.js new file mode 100644 index 0000000..4791b2b --- /dev/null +++ b/src/sim/material-tables.js @@ -0,0 +1,49 @@ +// Material tables: typed properties, casting groups, flags, parts and tool tiers. +// Typed property tables +const PH = new Uint8Array(NMAT), DENS = new Float32Array(NMAT), KC = new Float32Array(NMAT), ICAP = new Float32Array(NMAT), + HARD = new Uint8Array(NMAT), YLD = new Uint8Array(NMAT), MELT = new Float32Array(NMAT), MELTTO = new Uint8Array(NMAT), + SMELTP = new Float32Array(NMAT), FRZ = new Float32Array(NMAT), FRZTO = new Uint8Array(NMAT), BOIL = new Float32Array(NMAT), + BOILTO = new Uint8Array(NMAT), CONDT = new Float32Array(NMAT), CONDTO = new Uint8Array(NMAT), IGN = new Float32Array(NMAT), + FLAME = new Float32Array(NMAT), FLAMEF = new Float32Array(NMAT), FUEL = new Uint8Array(NMAT), BURNTO = new Uint8Array(NMAT), + ASHP = new Float32Array(NMAT), FLUID = new Float32Array(NMAT), SPREAD = new Uint8Array(NMAT), STICK = new Float32Array(NMAT), + ALPHA = new Float32Array(NMAT), LIQT = new Float32Array(NMAT), BAGOK = new Uint8Array(NMAT), PARTMAT = new Uint8Array(NMAT), + RELAX = new Float32Array(NMAT), COLR = new Uint8Array(NMAT), COLG = new Uint8Array(NMAT), COLB = new Uint8Array(NMAT), + CVAR = new Uint8Array(NMAT); +for (let i = 0; i < NMAT; i++) { + const d = MD[i]; + PH[i] = d.ph; DENS[i] = d.dens; KC[i] = d.k; ICAP[i] = 1 / d.cap; HARD[i] = d.hard; YLD[i] = d.yld; + MELT[i] = d.melt; MELTTO[i] = d.meltTo; SMELTP[i] = d.smelt; FRZ[i] = d.freeze; FRZTO[i] = d.freezeTo; + BOIL[i] = d.boil; BOILTO[i] = d.boilTo; CONDT[i] = d.condT; CONDTO[i] = d.condTo; IGN[i] = d.ign; FLAME[i] = d.flame; + FLAMEF[i] = d.flameF; FUEL[i] = d.fuel; BURNTO[i] = d.burnTo; ASHP[i] = d.ashP; FLUID[i] = d.fluid; SPREAD[i] = d.spread; + STICK[i] = d.stick; ALPHA[i] = d.alpha; LIQT[i] = d.liqT; BAGOK[i] = d.bag ? 1 : 0; PARTMAT[i] = d.partMat ? 1 : 0; + COLR[i] = d.col[0]; COLG[i] = d.col[1]; COLB[i] = d.col[2]; CVAR[i] = d.v; + RELAX[i] = d.ph === PH_LIQUID ? 0.0006 : d.ph === PH_POWDER ? 0.0005 : d.ph === PH_GAS ? 0.003 : 0.0004; +} +// what molds cast (molten metals, and molten glass) and what they set into +const CASTMETAL = new Uint8Array(NMAT); for (const m of [MCU, MFE, MST, MSN, MZN, MAG, MAU, MBZ, MBS, MGL]) CASTMETAL[m] = 1; +// tier as a drill bit or tank (0: too soft to be a bit) +const METAL_TIER = new Int8Array(NMAT).fill(-1); METAL_TIER[COPPER] = 1; METAL_TIER[IRON] = 2; METAL_TIER[STEEL] = 3; +METAL_TIER[TIN] = 0; METAL_TIER[GLASS] = 0; METAL_TIER[ZINC] = 0; METAL_TIER[SILVER] = 0; METAL_TIER[GOLD] = 0; METAL_TIER[BRONZE] = 1; METAL_TIER[BRASS] = 1; +const SOLIDMETAL = new Uint8Array(NMAT); for (const m of [COPPER, IRON, STEEL, TIN, ZINC, SILVER, GOLD, BRONZE, BRASS, GLASS]) SOLIDMETAL[m] = 1; + +// ---------- Flags & parts ---------- +const F_NAT = 1, F_CAST = 2, F_BURN = 4; +const P_NONE = 0, P_TU = 1, P_TD = 2, P_TL = 3, P_TR = 4, P_GRATE = 5, P_GOPEN = 6, P_WHEEL = 7, P_INTAKE = 8, P_FILTER = 9, P_PIPE = 10; +const TDX = [0, 0, 0, -1, 1], TDY = [0, -1, 1, 0, 0]; + +// ---------- Tool tiers ---------- +// reach: how far from the bot the drill works. Harder rock takes longer, and +// every new bit cuts faster and further. +const BIT_TIERS = [ + { name: 'Stone bit', hard: 3, speed: 0.7, reach: 16 }, + { name: 'Copper bit', hard: 4, speed: 1.0, reach: 24 }, + { name: 'Iron bit', hard: 5, speed: 1.5, reach: 34 }, + { name: 'Steel bit', hard: 6, speed: 2.2, reach: 46 }, +]; +const TANK_TIERS = [ + { name: 'Canvas sack', cap: 1500 }, + { name: 'Copper tank', cap: 4000 }, + { name: 'Iron tank', cap: 10000 }, + { name: 'Steel tank', cap: 25000 }, +]; +const REACH = 64; diff --git a/src/sim/materials.js b/src/sim/materials.js index 30b2119..bc671e0 100644 --- a/src/sim/materials.js +++ b/src/sim/materials.js @@ -1,4 +1,4 @@ -// Material ids, the material table (def), per-material property arrays, cell flags and part +// Material definitions: ids, properties and metal families. // ids, and drill/tank tiers. // ---------- Material ids ---------- const EMPTY = 0, GRASS = 1, DIRT = 2, LDIRT = 3, SAND = 4, GRAVEL = 5, CLAY = 6, STONE = 7, GRANITE = 8, BASALT = 9, @@ -117,52 +117,3 @@ def(BARSB, { name: 'Stone block', ph: PH_ITEM, col: [172, 166, 154], v: 0, dens: def(PIPE, { name: 'Pipe', col: [176, 112, 72], v: 4, k: 0.04, cap: 1.5, hard: 4, melt: 1085, meltTo: MCU, partMat: true }); def(DOCKW, { name: 'Trade dock', col: [70, 82, 96], v: 6, k: 0.01, cap: 2, hard: 9, yld: 0, partMat: true, desc: 'The claim office\'s trade dock. Whatever drops through the grate in its floor is sold.' }); def(HULL, { name: 'Machine', col: [84, 92, 102], v: 6, k: 0.03, cap: 1.5, hard: 4, melt: 1085, meltTo: MCU, partMat: true }); - -// Typed property tables -const PH = new Uint8Array(NMAT), DENS = new Float32Array(NMAT), KC = new Float32Array(NMAT), ICAP = new Float32Array(NMAT), - HARD = new Uint8Array(NMAT), YLD = new Uint8Array(NMAT), MELT = new Float32Array(NMAT), MELTTO = new Uint8Array(NMAT), - SMELTP = new Float32Array(NMAT), FRZ = new Float32Array(NMAT), FRZTO = new Uint8Array(NMAT), BOIL = new Float32Array(NMAT), - BOILTO = new Uint8Array(NMAT), CONDT = new Float32Array(NMAT), CONDTO = new Uint8Array(NMAT), IGN = new Float32Array(NMAT), - FLAME = new Float32Array(NMAT), FLAMEF = new Float32Array(NMAT), FUEL = new Uint8Array(NMAT), BURNTO = new Uint8Array(NMAT), - ASHP = new Float32Array(NMAT), FLUID = new Float32Array(NMAT), SPREAD = new Uint8Array(NMAT), STICK = new Float32Array(NMAT), - ALPHA = new Float32Array(NMAT), LIQT = new Float32Array(NMAT), BAGOK = new Uint8Array(NMAT), PARTMAT = new Uint8Array(NMAT), - RELAX = new Float32Array(NMAT), COLR = new Uint8Array(NMAT), COLG = new Uint8Array(NMAT), COLB = new Uint8Array(NMAT), - CVAR = new Uint8Array(NMAT); -for (let i = 0; i < NMAT; i++) { - const d = MD[i]; - PH[i] = d.ph; DENS[i] = d.dens; KC[i] = d.k; ICAP[i] = 1 / d.cap; HARD[i] = d.hard; YLD[i] = d.yld; - MELT[i] = d.melt; MELTTO[i] = d.meltTo; SMELTP[i] = d.smelt; FRZ[i] = d.freeze; FRZTO[i] = d.freezeTo; - BOIL[i] = d.boil; BOILTO[i] = d.boilTo; CONDT[i] = d.condT; CONDTO[i] = d.condTo; IGN[i] = d.ign; FLAME[i] = d.flame; - FLAMEF[i] = d.flameF; FUEL[i] = d.fuel; BURNTO[i] = d.burnTo; ASHP[i] = d.ashP; FLUID[i] = d.fluid; SPREAD[i] = d.spread; - STICK[i] = d.stick; ALPHA[i] = d.alpha; LIQT[i] = d.liqT; BAGOK[i] = d.bag ? 1 : 0; PARTMAT[i] = d.partMat ? 1 : 0; - COLR[i] = d.col[0]; COLG[i] = d.col[1]; COLB[i] = d.col[2]; CVAR[i] = d.v; - RELAX[i] = d.ph === PH_LIQUID ? 0.0006 : d.ph === PH_POWDER ? 0.0005 : d.ph === PH_GAS ? 0.003 : 0.0004; -} -// what molds cast (molten metals, and molten glass) and what they set into -const CASTMETAL = new Uint8Array(NMAT); for (const m of [MCU, MFE, MST, MSN, MZN, MAG, MAU, MBZ, MBS, MGL]) CASTMETAL[m] = 1; -// tier as a drill bit or tank (0: too soft to be a bit) -const METAL_TIER = new Int8Array(NMAT).fill(-1); METAL_TIER[COPPER] = 1; METAL_TIER[IRON] = 2; METAL_TIER[STEEL] = 3; -METAL_TIER[TIN] = 0; METAL_TIER[GLASS] = 0; METAL_TIER[ZINC] = 0; METAL_TIER[SILVER] = 0; METAL_TIER[GOLD] = 0; METAL_TIER[BRONZE] = 1; METAL_TIER[BRASS] = 1; -const SOLIDMETAL = new Uint8Array(NMAT); for (const m of [COPPER, IRON, STEEL, TIN, ZINC, SILVER, GOLD, BRONZE, BRASS, GLASS]) SOLIDMETAL[m] = 1; - -// ---------- Flags & parts ---------- -const F_NAT = 1, F_CAST = 2, F_BURN = 4; -const P_NONE = 0, P_TU = 1, P_TD = 2, P_TL = 3, P_TR = 4, P_GRATE = 5, P_GOPEN = 6, P_WHEEL = 7, P_INTAKE = 8, P_FILTER = 9, P_PIPE = 10; -const TDX = [0, 0, 0, -1, 1], TDY = [0, -1, 1, 0, 0]; - -// ---------- Tool tiers ---------- -// reach: how far from the bot the drill works. Harder rock takes longer, and -// every new bit cuts faster and further. -const BIT_TIERS = [ - { name: 'Stone bit', hard: 3, speed: 0.7, reach: 16 }, - { name: 'Copper bit', hard: 4, speed: 1.0, reach: 24 }, - { name: 'Iron bit', hard: 5, speed: 1.5, reach: 34 }, - { name: 'Steel bit', hard: 6, speed: 2.2, reach: 46 }, -]; -const TANK_TIERS = [ - { name: 'Canvas sack', cap: 1500 }, - { name: 'Copper tank', cap: 4000 }, - { name: 'Iron tank', cap: 10000 }, - { name: 'Steel tank', cap: 25000 }, -]; -const REACH = 64; diff --git a/src/sim/molds.js b/src/sim/molds.js index e26d6b9..b2ab592 100644 --- a/src/sim/molds.js +++ b/src/sim/molds.js @@ -309,9 +309,7 @@ function removeMold(mo) { for (const i of walls) { const m = mat[i]; if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m]) { - if (BAR_OF[m] && PH[mo.wall] === PH_ITEM) { metal++; setCell(i, EMPTY, temp[i], 0); } - else if (YLD[m] && bagAdd(YLD[m], 1, false) === 0) { setCell(i, YLD[m], temp[i], 0); droppedLast++; } - else setCell(i, EMPTY, temp[i], 0); + metal += removeWallCell(i, mo.wall); } } if (metal) giveOrDrop(BAR_OF[WALL_OF[mo.wall]] || BARCU, Math.floor(metal / GRAINS_PER_BAR), mo.x + 8, mo.y); diff --git a/src/sim/world.js b/src/sim/world.js index b606446..1456686 100644 --- a/src/sim/world.js +++ b/src/sim/world.js @@ -67,8 +67,9 @@ function depthU(y) { } // the y at a fraction u of the underground depth (the inverse of depthU) function depthY(u) { return Math.round(surfBase + (!shallowD ? u * underD : u <= 0.3 ? u / 0.3 * shallowD : shallowD + (u - 0.3) / 0.7 * (underD - shallowD))); } +function meanSurface() { let s = 0; for (let x = 0; x < W; x++) s += surfY[x]; return s / W; } function computeAmbient() { - let sm = 0; for (let x = 0; x < W; x++) sm += surfY[x]; sm /= W; + const sm = meanSurface(); for (let y = 0; y < H; y++) { let a = 18; if (y > sm) a += Math.min(60, (y - sm) * 0.04); diff --git a/src/sim/worldgen-helpers.js b/src/sim/worldgen-helpers.js new file mode 100644 index 0000000..d729f3e --- /dev/null +++ b/src/sim/worldgen-helpers.js @@ -0,0 +1,61 @@ +// World generation helpers: noise and removal of tiny hard rock specks. +// ---------- Perlin noise ---------- +function makeNoise(rng) { + const perm = new Uint8Array(512), gx = new Float32Array(256), gy = new Float32Array(256); + const p = []; for (let i = 0; i < 256; i++) p.push(i); + for (let i = 255; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); const t = p[i]; p[i] = p[j]; p[j] = t; } + for (let i = 0; i < 512; i++) perm[i] = p[i & 255]; + for (let i = 0; i < 256; i++) { const a = rng() * Math.PI * 2; gx[i] = Math.cos(a); gy[i] = Math.sin(a); } + return function (x, y) { + const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi; + const X = xi & 255, Y = yi & 255; + const p0 = perm[Y], p1 = perm[Y + 1]; + const h00 = perm[X + p0], h10 = perm[X + 1 + p0], h01 = perm[X + p1], h11 = perm[X + 1 + p1]; + const n00 = gx[h00] * xf + gy[h00] * yf, n10 = gx[h10] * (xf - 1) + gy[h10] * yf; + const n01 = gx[h01] * xf + gy[h01] * (yf - 1), n11 = gx[h11] * (xf - 1) + gy[h11] * (yf - 1); + const u = xf * xf * xf * (xf * (xf * 6 - 15) + 10), v = yf * yf * yf * (yf * (yf * 6 - 15) + 10); + return (n00 + u * (n10 - n00) + v * (n01 - n00) + u * v * (n00 - n10 - n01 + n11)) * 1.41; + }; +} +function fbm(n, x, y, oct) { let s = 0, a = 1, f = 1, t = 0; for (let o = 0; o < oct; o++) { s += n(x * f, y * f) * a; t += a; a *= 0.5; f *= 2; } return s / t; } + +// Small specks of rock harder than the rock around them (a few grains of iron ore in stone, a +// chip of granite, a sliver of basalt) are not worth having: a bit too weak to break them leaves +// them hanging in the way. Any such piece of SPECK_MIN grains or fewer becomes the softer rock +// around it. Bigger veins and pockets stay. live: the world is in play (an older save). +const SPECK_MIN = 30; +function clearHardSpecks(live) { + const seen = new Uint8Array(N), st = new Int32Array(N), cells = new Int32Array(SPECK_MIN + 1); + let n = 0; + for (const h0 of [6, 5, 4]) { + seen.fill(0); + const mem = (i) => { const m = mat[i]; return (flags[i] & F_NAT) !== 0 && PH[m] === PH_SOLID && HARD[m] >= h0 && HARD[m] <= 6 && YLD[m] !== 0 && !PARTMAT[m]; }; + const soft = (i) => { const m = mat[i]; return (flags[i] & F_NAT) !== 0 && PH[m] === PH_SOLID && HARD[m] >= 1 && HARD[m] < h0 && YLD[m] !== 0 && !PARTMAT[m]; }; + for (let i0 = 0; i0 < N; i0++) { + if (seen[i0] || !mem(i0)) continue; + let sp = 0, sz = 0, x0 = W, y0 = H, x1 = 0, y1 = 0; + const hosts = new Map(); + st[sp++] = i0; seen[i0] = 1; + while (sp) { + const i = st[--sp], x = i % W, y = (i / W) | 0; + if (sz <= SPECK_MIN) cells[sz] = i; + sz++; + if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; + for (const j of [x > 0 ? i - 1 : -1, x < W - 1 ? i + 1 : -1, i - W, i + W]) { + if (j < 0 || j >= N || seen[j]) continue; + if (mem(j)) { seen[j] = 1; st[sp++] = j; } + else if (soft(j)) hosts.set(mat[j], (hosts.get(mat[j]) || 0) + 1); + } + } + if (sz > SPECK_MIN) continue; + // a speck left floating (in a cave, or dug round) takes the rock nearest round it + if (!hosts.size) for (let y = Math.max(0, y0 - 5); y <= Math.min(H - 1, y1 + 5); y++) for (let x = Math.max(0, x0 - 5); x <= Math.min(W - 1, x1 + 5); x++) { const j = y * W + x; if (soft(j)) hosts.set(mat[j], (hosts.get(mat[j]) || 0) + 1); } + let host = 0, best = 0; + for (const [m, c] of hosts) if (c > best) { best = c; host = m; } + if (!host) continue; + for (let k = 0; k < sz; k++) { const i = cells[k]; if (live) setCell(i, host, temp[i], F_NAT); else mat[i] = host; } + n++; + } + } + return n; +} diff --git a/src/sim/worldgen.js b/src/sim/worldgen.js index 68425e1..dc194b9 100644 --- a/src/sim/worldgen.js +++ b/src/sim/worldgen.js @@ -1,64 +1,4 @@ -// World generation: noise, layers, ores, caves, water, trees, and clearing tiny hard specks. -// ---------- Perlin noise ---------- -function makeNoise(rng) { - const perm = new Uint8Array(512), gx = new Float32Array(256), gy = new Float32Array(256); - const p = []; for (let i = 0; i < 256; i++) p.push(i); - for (let i = 255; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); const t = p[i]; p[i] = p[j]; p[j] = t; } - for (let i = 0; i < 512; i++) perm[i] = p[i & 255]; - for (let i = 0; i < 256; i++) { const a = rng() * Math.PI * 2; gx[i] = Math.cos(a); gy[i] = Math.sin(a); } - return function (x, y) { - const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi; - const X = xi & 255, Y = yi & 255; - const p0 = perm[Y], p1 = perm[Y + 1]; - const h00 = perm[X + p0], h10 = perm[X + 1 + p0], h01 = perm[X + p1], h11 = perm[X + 1 + p1]; - const n00 = gx[h00] * xf + gy[h00] * yf, n10 = gx[h10] * (xf - 1) + gy[h10] * yf; - const n01 = gx[h01] * xf + gy[h01] * (yf - 1), n11 = gx[h11] * (xf - 1) + gy[h11] * (yf - 1); - const u = xf * xf * xf * (xf * (xf * 6 - 15) + 10), v = yf * yf * yf * (yf * (yf * 6 - 15) + 10); - return (n00 + u * (n10 - n00) + v * (n01 - n00) + u * v * (n00 - n10 - n01 + n11)) * 1.41; - }; -} -function fbm(n, x, y, oct) { let s = 0, a = 1, f = 1, t = 0; for (let o = 0; o < oct; o++) { s += n(x * f, y * f) * a; t += a; a *= 0.5; f *= 2; } return s / t; } - -// Small specks of rock harder than the rock around them (a few grains of iron ore in stone, a -// chip of granite, a sliver of basalt) are not worth having: a bit too weak to break them leaves -// them hanging in the way. Any such piece of SPECK_MIN grains or fewer becomes the softer rock -// around it. Bigger veins and pockets stay. live: the world is in play (an older save). -const SPECK_MIN = 30; -function clearHardSpecks(live) { - const seen = new Uint8Array(N), st = new Int32Array(N), cells = new Int32Array(SPECK_MIN + 1); - let n = 0; - for (const h0 of [6, 5, 4]) { - seen.fill(0); - const mem = (i) => { const m = mat[i]; return (flags[i] & F_NAT) !== 0 && PH[m] === PH_SOLID && HARD[m] >= h0 && HARD[m] <= 6 && YLD[m] !== 0 && !PARTMAT[m]; }; - const soft = (i) => { const m = mat[i]; return (flags[i] & F_NAT) !== 0 && PH[m] === PH_SOLID && HARD[m] >= 1 && HARD[m] < h0 && YLD[m] !== 0 && !PARTMAT[m]; }; - for (let i0 = 0; i0 < N; i0++) { - if (seen[i0] || !mem(i0)) continue; - let sp = 0, sz = 0, x0 = W, y0 = H, x1 = 0, y1 = 0; - const hosts = new Map(); - st[sp++] = i0; seen[i0] = 1; - while (sp) { - const i = st[--sp], x = i % W, y = (i / W) | 0; - if (sz <= SPECK_MIN) cells[sz] = i; - sz++; - if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; - for (const j of [x > 0 ? i - 1 : -1, x < W - 1 ? i + 1 : -1, i - W, i + W]) { - if (j < 0 || j >= N || seen[j]) continue; - if (mem(j)) { seen[j] = 1; st[sp++] = j; } - else if (soft(j)) hosts.set(mat[j], (hosts.get(mat[j]) || 0) + 1); - } - } - if (sz > SPECK_MIN) continue; - // a speck left floating (in a cave, or dug round) takes the rock nearest round it - if (!hosts.size) for (let y = Math.max(0, y0 - 5); y <= Math.min(H - 1, y1 + 5); y++) for (let x = Math.max(0, x0 - 5); x <= Math.min(W - 1, x1 + 5); x++) { const j = y * W + x; if (soft(j)) hosts.set(mat[j], (hosts.get(mat[j]) || 0) + 1); } - let host = 0, best = 0; - for (const [m, c] of hosts) if (c > best) { best = c; host = m; } - if (!host) continue; - for (let k = 0; k < sz; k++) { const i = cells[k]; if (live) setCell(i, host, temp[i], F_NAT); else mat[i] = host; } - n++; - } - } - return n; -} +// World generation: staged terrain, ore, caves and placement, with synchronous and async entry points. // ---------- World generation ---------- function* worldSteps(seedIn, w, h) { yield [0, 'Shaping the land']; diff --git a/src/ui/actions.js b/src/ui/actions.js index c368c44..8f1fc1a 100644 --- a/src/ui/actions.js +++ b/src/ui/actions.js @@ -124,7 +124,7 @@ function primaryPress(shift) { const P = piece(); if (P.kind === 'block') { GAME.drag = aimBlock(); } else { - const bw = P.kind === 'mold' ? MOLDS[P.mold].bw : P.bw, bh = P.kind === 'mold' ? MOLDS[P.mold].bh : P.bh; + const { bw, bh } = pieceSize(P); const { bx, by } = stampOrigin(bw, bh); if (!inReach(bx * BS + bw * BS / 2, by * BS + bh * BS / 2)) { setStatus('Out of reach.'); playSfx('error'); return; } const place = () => P.kind === 'mold' ? placeMoldAt(P.mold, bx, by, GAME.wallMat) : placeMachineAt(P.id, GAME.pieceDir[P.id] || 0, bx, by); diff --git a/src/ui/info.js b/src/ui/info.js index c57ed00..87edfbf 100644 --- a/src/ui/info.js +++ b/src/ui/info.js @@ -114,13 +114,13 @@ function hoverInfo(ax, ay) { } else if (p === P_PIPE) { const pb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pn = pipeNetOf(pb); t1 = 'Pipe' + (m !== EMPTY ? ` · ${MD[m].name} flowing past` : ''); - t2.push(pn && pn.n ? `line holds ${pn.n.toLocaleString('en-US')} / ${pn.cap.toLocaleString('en-US')} ${MD[pn.m].name.toLowerCase()}` : `line empty (room for ${pn ? pn.cap : PIPE_CAP})`); + t2.push(pipeContentsText(pn)); } else if (p) t1 = ({ 1: 'Screw tube ↑', 2: 'Screw tube ↓', 3: 'Screw tube ←', 4: 'Screw tube →', 5: 'Grate', 6: 'Gate (open)', 7: 'Water wheel', 8: 'Engine intake' })[p] + (m !== EMPTY ? ` · ${MD[m].name}` : ''); else if (m === BELTL || m === BELTR) t1 = 'Belt ' + (m === BELTL ? '←' : '→'); else if (m === PIPE && isPipeCode(b)) { const pb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pn = pipeNetOf(pb), v = blockV[pb], arrows = ['↑', '→', '↓', '←']; t1 = b === BP_PUMP ? `Pump ${arrows[v & 3]}` : b === BP_OUTLET ? `Outlet ${['↓', '←', '↑', '→'][v & 3]}` : 'Pipe'; - t2.push(pn && pn.n ? `line holds ${pn.n.toLocaleString('en-US')} / ${pn.cap.toLocaleString('en-US')} ${MD[pn.m].name.toLowerCase()}` : `line empty (room for ${pn ? pn.cap : PIPE_CAP})`); + t2.push(pipeContentsText(pn)); if (b === BP_PUMP) t2.push(v & 4 ? 'off · press F' : 'on · F turns it off'); if (b === BP_OUTLET) t2.push(v & 4 ? 'closed · press F' : 'open · F closes it'); } @@ -184,3 +184,5 @@ function drawCursorLabel(px, py, r) { if (sub) { ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`; ctx.fillStyle = info.warn ? '#ef6a55' : '#ffd27a'; ctx.fillText(sub, x + padX + w1 + fs * 0.6, y + h / 2); } ctx.restore(); } + +function pipeContentsText(pn) { return pn && pn.n ? `line holds ${pn.n.toLocaleString('en-US')} / ${pn.cap.toLocaleString('en-US')} ${MD[pn.m].name.toLowerCase()}` : `line empty (room for ${pn ? pn.cap : PIPE_CAP})`; } diff --git a/src/ui/notebook-blueprints.js b/src/ui/notebook-blueprints.js new file mode 100644 index 0000000..a3f2298 --- /dev/null +++ b/src/ui/notebook-blueprints.js @@ -0,0 +1,47 @@ +// Notebook blueprints: starter designs, saved library and code sharing. +function renderBlueprintTab(body) { + const lib = libLoad(); + let h = `

Select the Blueprint tool (5), drag over something you built to copy it, then click to place a ghost. Your bot builds ghosts from the materials in your bag when it is within reach. Right-click clears ghosts.

`; + h += `

Starter designs

Optional. Tested builds to learn from or copy; place one as a ghost and your bot builds it from your bag. Clear the space first: ghosts wait for open air.

`; + STARTER_DESIGNS.forEach((d, k) => { + const bp = d.bp(); + h += `
${esc(d.name)}${bp.bw}×${bp.bh} blocks
${esc(d.desc)}
Build cost: ${esc(costText(bpCost(bp), 1))}
`; + }); + h += '

Your blueprints

'; + h += `
Clipboard
${GAME.clip ? `${GAME.clip.bw} × ${GAME.clip.bh} blocks, ${GAME.clip.blocks.length} pieces${GAME.clip.machines.length ? `, ${GAME.clip.machines.length} machines` : ''}${GAME.clip.molds.length ? `, ${GAME.clip.molds.length} molds` : ''}. Needs ${esc(costText(bpCost(GAME.clip), 1) || 'nothing')}` : 'Empty'}
`; + h += `
Import a blueprint code
Paste a code that starts with DGBP2.
`; + h += `

Library

`; + if (!lib.length) h += `

No saved blueprints yet.

`; + else { + h += '
'; + lib.forEach((it, k) => { + const ok = bpValid(it.bp); + h += `
${esc(it.name)}${ok ? `${it.bp.bw}×${it.bp.bh}` : 'old format'}
${ok ? esc(costText(bpCost(it.bp), 1) || 'free') : 'Made before the block grid. It cannot be placed.'}
${ok ? `` : ''}
`; + }); + h += '
'; + } + body.innerHTML = h; + const sv = $('bp-save'); if (sv) sv.onclick = () => { + dialog('Name this blueprint', '', [ + { label: 'Cancel', cls: 'ghost' }, + { label: 'Save', cls: 'primary', fn: () => { const n = ($('bp-name').value || 'Untitled').trim(); const l = libLoad(); l.push({ name: n, bp: GAME.clip }); if (libSave(l)) toast(`Saved “${n}”.`); renderNotebook(); } }, + ]); + setTimeout(() => $('bp-name') && $('bp-name').focus(), 30); + }; + const cc = $('bp-code'); if (cc) cc.onclick = () => copyCode(bpEncode(GAME.clip)); + $('bp-import').onclick = () => { + dialog('Import blueprint', '', [ + { label: 'Cancel', cls: 'ghost' }, + { label: 'Import', cls: 'primary', fn: () => { try { GAME.clip = bpDecode($('bp-paste').value); GAME.paste = true; hudDirty = true; toast('Blueprint ready. Click to place it.'); closeAllModals(); } catch (e) { toast(e.message); return true; } } }, + ]); + }; + body.querySelectorAll('[data-bpload]').forEach(b => b.onclick = () => { GAME.clip = libLoad()[+b.dataset.bpload].bp; GAME.paste = true; hudDirty = true; closeAllModals(); toast('Blueprint ready. Click to place it.'); }); + body.querySelectorAll('[data-starter]').forEach(b => b.onclick = () => { GAME.clip = STARTER_DESIGNS[+b.dataset.starter].bp(); GAME.paste = true; hudDirty = true; closeAllModals(); toast('Click to place it. R flips it.'); }); + body.querySelectorAll('[data-bpcode]').forEach(b => b.onclick = () => copyCode(bpEncode(libLoad()[+b.dataset.bpcode].bp))); + body.querySelectorAll('[data-bpdel]').forEach(b => b.onclick = () => { const l = libLoad(); l.splice(+b.dataset.bpdel, 1); libSave(l); renderNotebook(); }); +} +function copyCode(code) { + const done = () => toast('Blueprint code copied.'); + const fallback = () => { dialog('Blueprint code', `

Select the text and copy it.

`, [{ label: 'Done', cls: 'primary' }]); setTimeout(() => { const t = $('bp-out'); if (t) { t.focus(); t.select(); } }, 30); }; + try { navigator.clipboard.writeText(code).then(done, fallback); } catch (e) { fallback(); } +} diff --git a/src/ui/notebook-market.js b/src/ui/notebook-market.js new file mode 100644 index 0000000..1e8c9a0 --- /dev/null +++ b/src/ui/notebook-market.js @@ -0,0 +1,68 @@ +// Notebook market: prices, orders, claim payments and wallet. +function fmtClock(ticks) { const s = Math.max(0, Math.ceil(ticks / 60)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; } +function orderName(o) { return `${o.who ? o.who + ': ' : ''}${o.qty.toLocaleString('en-US')} ${MD[o.m].name.toLowerCase()}${PH[o.m] === PH_ITEM && o.qty !== 1 ? 's' : ''}`; } +function orderLeft(o) { return Math.max(0, o.due - tick); } +const MARKET_GROUPS = [ + ['Raw', [COAL, LWOOD, ACORN, SAND, LCLAY, RGRAN, RBAS, CUORE, FEORE, SNORE, ZNORE]], + ['Bulk (builders)', [RSTONE, GRAVEL, BARSB, PLANK, GREENB, BARBR]], + ['Refined', [CHARCOAL, RBRICK, CULLET, BARGL, SCRCU, SCRFE, SCRST, SCRSN, SCRZN, BARCU, BARSN, BARZN, BARFE, BARST]], + ['Alloys', [SCRBZ, SCRBS, BARBZ, BARBS]], + ['Precious', [AGORE, AUORE, SCRAG, SCRAU, BARAG, BARAU]], +]; +function renderMarketTab(body) { + let h = `
Money
${fmtMoney(money)}
` + + `
${debt > 0 ? 'Still owed on the claim' : 'The claim'}
${debt > 0 ? fmtMoney(debt) : 'Paid off'}
` + + `
Earned in total
${fmtMoney(earned)}
`; + if (debt > 0) h += `
Pay toward the claim:${[1000, 5000].map(a => ``).join('')}
`; + else h += `

The land is yours. Keep building: the ledger has bigger goals.

`; + h += `

Anything that drops through the trade dock's grate is sold on the spot, once it is cool enough to handle. Junk like dirt, ash and slag goes for nothing. Rock you dig still sells, builders order it by the bagful, and a Grinder finds ore in it. Build walls around the dock to funnel more in.

`; + h += `

Orders · ${esc(STANDING[standing()])}

`; + if (orders.length) { + h += '
' + orders.map(o => `
${esc(orderName(o))}${fmtMoney(o.pay)}` + + `${o.got} of ${o.qty} delivered · ${o.mult}× market · ${fmtClock(orderLeft(o))} left
`).join('') + '
'; + } else h += `

No open orders. Buyers post new ones every minute or two, for goods you have found.

`; + h += `

Goods dropped into the dock fill a matching order first. A missed deadline costs nothing: what you delivered is paid at market price. ${ordersDone} filled so far; bigger buyers come with more.

`; + h += `

Prices

Per grain, or per bar. Selling a lot of one thing lowers its price for a while; it recovers over about half an hour.

`; + let rows = '', unknown = 0; + for (const [g, list] of MARKET_GROUPS) { + const known = list.filter(m => seen[m]); + unknown += list.length - known.length; + if (!known.length) continue; + rows += `${esc(g)}`; + for (const m of known) { + const f = priceFactor(m), now = priceNow(m); + rows += `${esc(MD[m].name)}${fmtPrice(PRICE[m])}${fmtPrice(now)}${f < 0.97 ? ` (${Math.round(f * 100)}%)` : ''}`; + } + } + h += rows ? `
${rows}
GoodsBaseNow
` : '

Find something worth selling first.

'; + if (unknown) h += `

${unknown} more goods to discover.

`; + h += `

Sales, minute by minute

`; + const recent = saleAcc.total > 0 ? [{ total: saleAcc.total, items: saleAcc.items, now: true }].concat(shipLog) : shipLog; + h += recent.length ? `
${recent.map(sl => `
${sl.now ? 'This minute: ' : ''}${esc(itemsText(sl.items))}+${fmtMoney(sl.total)}
`).join('')}
` : '

Nothing sold yet.

'; + body.innerHTML = h; + body.querySelectorAll('[data-pay]').forEach(b => b.onclick = () => { + const v = b.dataset.pay === 'all' ? money : +b.dataset.pay; + const paid = payClaim(v); + if (paid > 0) { playSfx('sale'); toast(`Paid ${fmtMoney(paid)} toward the claim.`); } + renderNotebook(); hudDirty = true; + }); +} +function fmtPrice(v) { return v >= 10 ? '$' + Math.round(v) : '$' + v.toFixed(2); } +function itemsText(items) { + const e = Object.entries(items).map(([m, n]) => [+m, n]).sort((a, b) => b[1] * PRICE[b[0]] - a[1] * PRICE[a[0]]); + const parts = e.slice(0, 3).map(([m, n]) => PH[m] === PH_ITEM ? `${n} ${MD[m].name.toLowerCase()}${n === 1 ? '' : 's'}` : `${n} ${MD[m].name.toLowerCase()}`); + if (e.length > 3) parts.push(`${e.length - 3} more`); + return parts.join(', '); +} +function renderWallet() { + $('w-money').textContent = fmtMoney(money); + const c = $('w-claim'); + c.className = 'line' + (debt <= 0 ? ' paid' : ''); + c.innerHTML = debt > 0 ? `Claim owed${fmtMoney(debt)}` : `Claimpaid off ✓`; + const h = orders.length ? orders.map(o => { + const left = orderLeft(o); + return `${o.got}/${esc(orderName(o))}${fmtClock(left)}`; + }).join('') : 'No orders yet'; + const box = $('w-orders'); + if (box.innerHTML !== h) box.innerHTML = h; +} diff --git a/src/ui/notebook.js b/src/ui/notebook.js index 2366769..a9a6cb6 100644 --- a/src/ui/notebook.js +++ b/src/ui/notebook.js @@ -1,4 +1,4 @@ -// The notebook (N): milestones, market, wallet, blueprints, controls. +// Notebook: tab navigation, ledger, materials, pieces and controls. // ---------- Notebook ---------- let nbTab = 'Ledger'; function openNotebook(tab) { if (tab) nbTab = tab; renderNotebook(); openModal('m-notebook'); } @@ -55,119 +55,6 @@ function renderNotebook() { body.innerHTML = controlsHtml(); } } -function fmtClock(ticks) { const s = Math.max(0, Math.ceil(ticks / 60)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; } -function orderName(o) { return `${o.who ? o.who + ': ' : ''}${o.qty.toLocaleString('en-US')} ${MD[o.m].name.toLowerCase()}${PH[o.m] === PH_ITEM && o.qty !== 1 ? 's' : ''}`; } -function orderLeft(o) { return Math.max(0, o.due - tick); } -const MARKET_GROUPS = [ - ['Raw', [COAL, LWOOD, ACORN, SAND, LCLAY, RGRAN, RBAS, CUORE, FEORE, SNORE, ZNORE]], - ['Bulk (builders)', [RSTONE, GRAVEL, BARSB, PLANK, GREENB, BARBR]], - ['Refined', [CHARCOAL, RBRICK, CULLET, BARGL, SCRCU, SCRFE, SCRST, SCRSN, SCRZN, BARCU, BARSN, BARZN, BARFE, BARST]], - ['Alloys', [SCRBZ, SCRBS, BARBZ, BARBS]], - ['Precious', [AGORE, AUORE, SCRAG, SCRAU, BARAG, BARAU]], -]; -function renderMarketTab(body) { - let h = `
Money
${fmtMoney(money)}
` - + `
${debt > 0 ? 'Still owed on the claim' : 'The claim'}
${debt > 0 ? fmtMoney(debt) : 'Paid off'}
` - + `
Earned in total
${fmtMoney(earned)}
`; - if (debt > 0) h += `
Pay toward the claim:${[1000, 5000].map(a => ``).join('')}
`; - else h += `

The land is yours. Keep building: the ledger has bigger goals.

`; - h += `

Anything that drops through the trade dock's grate is sold on the spot, once it is cool enough to handle. Junk like dirt, ash and slag goes for nothing. Rock you dig still sells, builders order it by the bagful, and a Grinder finds ore in it. Build walls around the dock to funnel more in.

`; - h += `

Orders · ${esc(STANDING[standing()])}

`; - if (orders.length) { - h += '
' + orders.map(o => `
${esc(orderName(o))}${fmtMoney(o.pay)}` - + `${o.got} of ${o.qty} delivered · ${o.mult}× market · ${fmtClock(orderLeft(o))} left
`).join('') + '
'; - } else h += `

No open orders. Buyers post new ones every minute or two, for goods you have found.

`; - h += `

Goods dropped into the dock fill a matching order first. A missed deadline costs nothing: what you delivered is paid at market price. ${ordersDone} filled so far; bigger buyers come with more.

`; - h += `

Prices

Per grain, or per bar. Selling a lot of one thing lowers its price for a while; it recovers over about half an hour.

`; - let rows = '', unknown = 0; - for (const [g, list] of MARKET_GROUPS) { - const known = list.filter(m => seen[m]); - unknown += list.length - known.length; - if (!known.length) continue; - rows += `${esc(g)}`; - for (const m of known) { - const f = priceFactor(m), now = priceNow(m); - rows += `${esc(MD[m].name)}${fmtPrice(PRICE[m])}${fmtPrice(now)}${f < 0.97 ? ` (${Math.round(f * 100)}%)` : ''}`; - } - } - h += rows ? `
${rows}
GoodsBaseNow
` : '

Find something worth selling first.

'; - if (unknown) h += `

${unknown} more goods to discover.

`; - h += `

Sales, minute by minute

`; - const recent = saleAcc.total > 0 ? [{ total: saleAcc.total, items: saleAcc.items, now: true }].concat(shipLog) : shipLog; - h += recent.length ? `
${recent.map(sl => `
${sl.now ? 'This minute: ' : ''}${esc(itemsText(sl.items))}+${fmtMoney(sl.total)}
`).join('')}
` : '

Nothing sold yet.

'; - body.innerHTML = h; - body.querySelectorAll('[data-pay]').forEach(b => b.onclick = () => { - const v = b.dataset.pay === 'all' ? money : +b.dataset.pay; - const paid = payClaim(v); - if (paid > 0) { playSfx('sale'); toast(`Paid ${fmtMoney(paid)} toward the claim.`); } - renderNotebook(); hudDirty = true; - }); -} -function fmtPrice(v) { return v >= 10 ? '$' + Math.round(v) : '$' + v.toFixed(2); } -function itemsText(items) { - const e = Object.entries(items).map(([m, n]) => [+m, n]).sort((a, b) => b[1] * PRICE[b[0]] - a[1] * PRICE[a[0]]); - const parts = e.slice(0, 3).map(([m, n]) => PH[m] === PH_ITEM ? `${n} ${MD[m].name.toLowerCase()}${n === 1 ? '' : 's'}` : `${n} ${MD[m].name.toLowerCase()}`); - if (e.length > 3) parts.push(`${e.length - 3} more`); - return parts.join(', '); -} -function renderWallet() { - $('w-money').textContent = fmtMoney(money); - const c = $('w-claim'); - c.className = 'line' + (debt <= 0 ? ' paid' : ''); - c.innerHTML = debt > 0 ? `Claim owed${fmtMoney(debt)}` : `Claimpaid off ✓`; - const h = orders.length ? orders.map(o => { - const left = orderLeft(o); - return `${o.got}/${esc(orderName(o))}${fmtClock(left)}`; - }).join('') : 'No orders yet'; - const box = $('w-orders'); - if (box.innerHTML !== h) box.innerHTML = h; -} -function renderBlueprintTab(body) { - const lib = libLoad(); - let h = `

Select the Blueprint tool (5), drag over something you built to copy it, then click to place a ghost. Your bot builds ghosts from the materials in your bag when it is within reach. Right-click clears ghosts.

`; - h += `

Starter designs

Optional. Tested builds to learn from or copy; place one as a ghost and your bot builds it from your bag. Clear the space first: ghosts wait for open air.

`; - STARTER_DESIGNS.forEach((d, k) => { - const bp = d.bp(); - h += `
${esc(d.name)}${bp.bw}×${bp.bh} blocks
${esc(d.desc)}
Build cost: ${esc(costText(bpCost(bp), 1))}
`; - }); - h += '

Your blueprints

'; - h += `
Clipboard
${GAME.clip ? `${GAME.clip.bw} × ${GAME.clip.bh} blocks, ${GAME.clip.blocks.length} pieces${GAME.clip.machines.length ? `, ${GAME.clip.machines.length} machines` : ''}${GAME.clip.molds.length ? `, ${GAME.clip.molds.length} molds` : ''}. Needs ${esc(costText(bpCost(GAME.clip), 1) || 'nothing')}` : 'Empty'}
`; - h += `
Import a blueprint code
Paste a code that starts with DGBP2.
`; - h += `

Library

`; - if (!lib.length) h += `

No saved blueprints yet.

`; - else { - h += '
'; - lib.forEach((it, k) => { - const ok = bpValid(it.bp); - h += `
${esc(it.name)}${ok ? `${it.bp.bw}×${it.bp.bh}` : 'old format'}
${ok ? esc(costText(bpCost(it.bp), 1) || 'free') : 'Made before the block grid. It cannot be placed.'}
${ok ? `` : ''}
`; - }); - h += '
'; - } - body.innerHTML = h; - const sv = $('bp-save'); if (sv) sv.onclick = () => { - dialog('Name this blueprint', '', [ - { label: 'Cancel', cls: 'ghost' }, - { label: 'Save', cls: 'primary', fn: () => { const n = ($('bp-name').value || 'Untitled').trim(); const l = libLoad(); l.push({ name: n, bp: GAME.clip }); if (libSave(l)) toast(`Saved “${n}”.`); renderNotebook(); } }, - ]); - setTimeout(() => $('bp-name') && $('bp-name').focus(), 30); - }; - const cc = $('bp-code'); if (cc) cc.onclick = () => copyCode(bpEncode(GAME.clip)); - $('bp-import').onclick = () => { - dialog('Import blueprint', '', [ - { label: 'Cancel', cls: 'ghost' }, - { label: 'Import', cls: 'primary', fn: () => { try { GAME.clip = bpDecode($('bp-paste').value); GAME.paste = true; hudDirty = true; toast('Blueprint ready. Click to place it.'); closeAllModals(); } catch (e) { toast(e.message); return true; } } }, - ]); - }; - body.querySelectorAll('[data-bpload]').forEach(b => b.onclick = () => { GAME.clip = libLoad()[+b.dataset.bpload].bp; GAME.paste = true; hudDirty = true; closeAllModals(); toast('Blueprint ready. Click to place it.'); }); - body.querySelectorAll('[data-starter]').forEach(b => b.onclick = () => { GAME.clip = STARTER_DESIGNS[+b.dataset.starter].bp(); GAME.paste = true; hudDirty = true; closeAllModals(); toast('Click to place it. R flips it.'); }); - body.querySelectorAll('[data-bpcode]').forEach(b => b.onclick = () => copyCode(bpEncode(libLoad()[+b.dataset.bpcode].bp))); - body.querySelectorAll('[data-bpdel]').forEach(b => b.onclick = () => { const l = libLoad(); l.splice(+b.dataset.bpdel, 1); libSave(l); renderNotebook(); }); -} -function copyCode(code) { - const done = () => toast('Blueprint code copied.'); - const fallback = () => { dialog('Blueprint code', `

Select the text and copy it.

`, [{ label: 'Done', cls: 'primary' }]); setTimeout(() => { const t = $('bp-out'); if (t) { t.focus(); t.select(); } }, 30); }; - try { navigator.clipboard.writeText(code).then(done, fallback); } catch (e) { fallback(); } -} function controlsHtml() { const kb = [ ['Fly', 'W A S D'], ['Boost while flying', 'Shift'], ['Tool modifier (while not flying)', 'Shift'], ['Use tool', 'Left mouse'], ['Second action', 'Right mouse'], diff --git a/src/ui/preview-drill.js b/src/ui/preview-drill.js new file mode 100644 index 0000000..6f53173 --- /dev/null +++ b/src/ui/preview-drill.js @@ -0,0 +1,104 @@ +// Drill previews: shaken rock cracks, pulse wavefronts and aiming fan. +// Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking. +// Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines +// run on unbroken from one cell to the next and look like real fractures, not a pattern. +const CRACK_N = [makeNoise(mulberry32(0xc7ac1)), makeNoise(mulberry32(0xc7ac2)), makeNoise(mulberry32(0xc7ac3))]; +const CRACK_LV = [{ s: 1 / 7, k: 0.1, w: 0.2 }, { s: 1 / 3.6, k: 0.42, w: 0.14 }, { s: 1 / 2, k: 0.72, w: 0.09 }]; +function crackSeg(ctx, x, y, n, sc, zp, oy) { + const v = (cx, cy) => n(cx * sc + 0.37, cy * sc + 0.61); + const a = v(x, y), b = v(x + 1, y), c = v(x + 1, y + 1), d = v(x, y + 1); + const pts = []; + if ((a < 0) !== (b < 0)) pts.push(x + a / (a - b), y); + if ((b < 0) !== (c < 0)) pts.push(x + 1, y + b / (b - c)); + if ((d < 0) !== (c < 0)) pts.push(x + d / (d - c), y + 1); + if ((a < 0) !== (d < 0)) pts.push(x, y + a / (a - d)); + for (let k = 0; k + 3 < pts.length; k += 4) { + ctx.moveTo((pts[k] - R.camX) * zp, (pts[k + 1] - R.camY) * zp + oy); + ctx.lineTo((pts[k + 2] - R.camX) * zp, (pts[k + 3] - R.camY) * zp + oy); + } +} +function drawCracks() { + const ctx = R.ctx, zp = R.zp; + if (zp < 2.5) return; + const x0 = Math.max(0, Math.floor(R.camX)), y0 = Math.max(0, Math.floor(R.camY)); + const x1 = Math.min(W - 1, Math.ceil(R.camX + R.canvas.width / zp)), y1 = Math.min(H - 1, Math.ceil(R.camY + R.canvas.height / zp)); + const cells = []; + for (let y = y0; y <= y1; y++) { const row = y * W; for (let x = x0; x <= x1; x++) { const dp = digp[row + x]; if (dp && PH[mat[row + x]] === PH_SOLID) cells.push(x, y, dp / 255); } } + if (!cells.length) return; + ctx.save(); ctx.lineCap = 'round'; + for (let l = 0; l < CRACK_LV.length; l++) { + const L = CRACK_LV[l]; + // a soft lit lip just below each crack, then the dark crack itself + for (const pass of [0, 1]) { + ctx.beginPath(); + for (let k = 0; k < cells.length; k += 3) if (cells[k + 2] >= L.k) crackSeg(ctx, cells[k], cells[k + 1], CRACK_N[l], L.s, zp, pass ? 0 : Math.max(1, zp * 0.12)); + ctx.strokeStyle = pass ? `rgba(18,12,10,${0.78 - l * 0.12})` : 'rgba(255,240,220,0.13)'; + ctx.lineWidth = Math.max(1, zp * L.w * (pass ? 1 : 1.3)); + ctx.stroke(); + } + } + ctx.restore(); +} +// The pulses in flight: a wavefront running out from the face of the bot, fading as it goes +function pulseRGB(tune) { + if (!tune) return '150,215,255'; + return `${Math.round((COLR[tune] + 255) / 2)},${Math.round((COLG[tune] + 255) / 2)},${Math.round((COLB[tune] + 255) / 2)}`; +} +// screen points across a fan (mouth at the bot, or out at distance sd along each ray) +function fanPoints(ox, oy, ang, Wd, sd, n) { + const pts = []; + for (let k = 0; k <= n; k++) { const r = pulseRay(ox, oy, ang, Wd, -1 + 2 * k / n); pts.push(worldToScreenPx(r.x + r.ux * sd, r.y + r.uy * sd)); } + return pts; +} +function drawDrillPulses() { + if (!drillPulses.length) return; + const ctx = R.ctx, zp = R.zp; + ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; + for (const q of drillPulses) { + const Wd = PULSE_W[q.w], rr = Math.min(q.r, q.range), a = Math.max(0, 1 - q.r / (q.range + 3)), col = pulseRGB(q.tune); + if (rr < 3) continue; + const front = fanPoints(q.ox, q.oy, q.ang, Wd, rr, 10), back = fanPoints(q.ox, q.oy, q.ang, Wd, Math.max(2.5, rr - 3), 10); + const line = (pts) => { ctx.beginPath(); pts.forEach((pt, k) => k ? ctx.lineTo(pt.x, pt.y) : ctx.moveTo(pt.x, pt.y)); }; + // a faint band behind the front, then the bright front itself + ctx.fillStyle = `rgba(${col},${(0.08 * a).toFixed(3)})`; + line(front); for (let k = back.length - 1; k >= 0; k--) ctx.lineTo(back[k].x, back[k].y); ctx.closePath(); ctx.fill(); + ctx.strokeStyle = `rgba(${col},${(0.2 * a).toFixed(3)})`; ctx.lineWidth = Math.max(3, zp * 1.3); line(front); ctx.stroke(); + ctx.strokeStyle = `rgba(${col},${(0.85 * a).toFixed(3)})`; ctx.lineWidth = Math.max(1.5, zp * 0.3); line(front); ctx.stroke(); + } + ctx.restore(); +} +// With the drill in hand: the fan a pulse will sweep (as wide as the bot where it leaves it), +// out to where it stops, and a crosshair +function drawDrillAim(ax, ay) { + const ctx = R.ctx, zp = R.zp; + const o = drillOrigin(), c = worldToScreenPx(o.x, o.y), p = worldToScreenPx(ax + 0.5, ay + 0.5); + const d = Math.hypot(ax + 0.5 - o.x, ay + 0.5 - o.y), ang = Math.atan2(ay + 0.5 - o.y, ax + 0.5 - o.x); + const Wd = PULSE_W[GAME.drillW], range = Math.max(3, Math.min(drillReach(GAME.drillW), d + 3)); + const ok = inDrillReach(ax, ay, GAME.drillW), col = ok ? pulseRGB(GAME.drillTune) : '239,106,85'; + const mouth = fanPoints(o.x, o.y, ang, Wd, 2.5, 1), far = fanPoints(o.x, o.y, ang, Wd, range, 10); + ctx.save(); + // the outline gets a dark under-stroke so it reads on bright sky as well as dark rock + const outline = () => { ctx.beginPath(); ctx.moveTo(mouth[0].x, mouth[0].y); for (const pt of far) ctx.lineTo(pt.x, pt.y); ctx.lineTo(mouth[1].x, mouth[1].y); ctx.closePath(); }; + ctx.fillStyle = `rgba(${col},0.06)`; outline(); ctx.fill(); + ctx.setLineDash([zp * 0.8, zp * 0.8]); ctx.lineDashOffset = -(R.frame * 0.3) % (zp * 1.6); + ctx.strokeStyle = 'rgba(10,14,20,0.28)'; ctx.lineWidth = Math.max(2, zp * 0.3); ctx.stroke(); + ctx.strokeStyle = `rgba(${col},0.6)`; ctx.lineWidth = Math.max(1, zp * 0.14); ctx.stroke(); + ctx.setLineDash([]); ctx.lineDashOffset = 0; + // the emitter glows across the bot's face while it fires + const hot = Math.max(0, 1 - (tick - lastPulseTick) / PULSE_EVERY); + if (GAME.lmb && hot > 0) { + ctx.globalCompositeOperation = 'lighter'; + ctx.strokeStyle = `rgba(${col},${(0.7 * hot).toFixed(3)})`; ctx.lineWidth = Math.max(2, zp * 0.6); ctx.lineCap = 'round'; + ctx.beginPath(); ctx.moveTo(mouth[0].x, mouth[0].y); ctx.lineTo(mouth[1].x, mouth[1].y); ctx.stroke(); + const g = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, zp * 3.5); + g.addColorStop(0, `rgba(${col},${(0.35 * hot).toFixed(3)})`); g.addColorStop(1, `rgba(${col},0)`); + ctx.fillStyle = g; ctx.fillRect(c.x - zp * 3.5, c.y - zp * 3.5, zp * 7, zp * 7); + ctx.globalCompositeOperation = 'source-over'; + } + ctx.strokeStyle = ok ? 'rgba(239,230,216,0.85)' : 'rgba(239,106,85,0.85)'; ctx.lineWidth = Math.max(1, zp * 0.2); + const k = zp * 0.9; + ctx.beginPath(); ctx.moveTo(p.x - k * 1.6, p.y); ctx.lineTo(p.x - k * 0.5, p.y); ctx.moveTo(p.x + k * 0.5, p.y); ctx.lineTo(p.x + k * 1.6, p.y); + ctx.moveTo(p.x, p.y - k * 1.6); ctx.lineTo(p.x, p.y - k * 0.5); ctx.moveTo(p.x, p.y + k * 0.5); ctx.lineTo(p.x, p.y + k * 1.6); ctx.stroke(); + if (GAME.drillTune) { const sz = Math.max(6, zp * 1.4); ctx.fillStyle = rgbOf(GAME.drillTune); ctx.fillRect(p.x + k * 1.8, p.y - k * 1.8 - sz, sz, sz); ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.strokeRect(p.x + k * 1.8 + 0.5, p.y - k * 1.8 - sz + 0.5, sz - 1, sz - 1); } + ctx.restore(); +} diff --git a/src/ui/preview-selection.js b/src/ui/preview-selection.js new file mode 100644 index 0000000..6517f60 --- /dev/null +++ b/src/ui/preview-selection.js @@ -0,0 +1,80 @@ +// Selection previews: labelled tags, copy and cut boxes, and paste ghosts. +// a small labelled tag drawn on the world (screen pixels) +function drawTag(text, col, x, y, sub) { + const ctx = R.ctx, u = Math.max(1, R.canvas.width / window.innerWidth); + renderMonoFont(); + ctx.save(); + ctx.font = `700 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.textBaseline = 'middle'; + const tw = ctx.measureText(text).width; + ctx.font = `500 ${Math.round(11 * u)}px ${R.monoFont}`; + const sw = sub ? ctx.measureText(sub).width + 8 * u : 0; + const h = Math.round(19 * u), pad = 7 * u, w = tw + sw + pad * 2; + x = Math.max(2, Math.min(R.canvas.width - w - 2, x)); y = Math.max(2, Math.min(R.canvas.height - h - 2, y)); + ctx.fillStyle = 'rgba(14,12,11,0.9)'; ctx.fillRect(x, y, w, h); + ctx.fillStyle = `rgb(${col})`; ctx.fillRect(x, y, 3 * u, h); + ctx.strokeStyle = `rgba(${col},0.8)`; ctx.lineWidth = u; ctx.strokeRect(x + u / 2, y + u / 2, w - u, h - u); + ctx.font = `700 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.fillStyle = `rgb(${col})`; ctx.fillText(text, x + pad, y + h / 2 + u * 0.5); + if (sub) { ctx.font = `500 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.fillStyle = 'rgba(231,220,203,0.9)'; ctx.fillText(sub, x + pad + tw + 8 * u, y + h / 2 + u * 0.5); } + ctx.restore(); +} +// Copy (teal) and cut (red, hatched): the box being dragged, what it would pick up, and a tag +const SEL_COL = { copy: '92,186,166', cut: '239,106,85' }; +function drawSelection(mode, ax, ay) { + const ctx = R.ctx, zp = R.zp, col = SEL_COL[mode], sd = GAME.selDrag; + const sx = sd ? sd.x : ax, sy = sd ? sd.y : ay; + const a = blockOf(Math.min(sx, ax), Math.min(sy, ay)), c = blockOf(Math.max(sx, ax), Math.max(sy, ay)); + const bw = c.bx - a.bx + 1, bh = c.by - a.by + 1; + drawGrid(blockOf(ax, ay).bx, blockOf(ax, ay).by, 7); + ctx.save(); + // what is inside: built blocks, and machines and molds that fit wholly inside + let n = 0; + if (sd) { + ctx.fillStyle = `rgba(${col},${mode === 'cut' ? 0.42 : 0.3})`; + for (let by = a.by; by <= c.by; by++) for (let bx = a.bx; bx <= c.bx; bx++) { + if (bx < 0 || by < 0 || bx >= BW || by >= BH) continue; + const b = by * BW + bx; + if (blockP[b] && blockIntegrity(b) >= 8) { const [x, y, w, h] = blockRectPx(bx, by, 1, 1); ctx.fillRect(x, y, w, h); n++; } + } + ctx.strokeStyle = `rgba(${col},0.95)`; ctx.lineWidth = Math.max(1, zp * 0.35); + for (const mm of machines) { + if (!mm || PIECE_BY_ID[mm.type].fixed) continue; + const P = PIECE_BY_ID[mm.type], mbx = mm.x / BS, mby = mm.y / BS; + if (mbx >= a.bx && mby >= a.by && mbx + P.bw - 1 <= c.bx && mby + P.bh - 1 <= c.by) { const [x, y, w, h] = blockRectPx(mbx, mby, P.bw, P.bh); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); n++; } + } + for (const mo of molds) { + const md = MOLDS[mo.kind], mbx = mo.x / BS, mby = mo.y / BS; + if (mbx >= a.bx && mby >= a.by && mbx + md.bw - 1 <= c.bx && mby + md.bh - 1 <= c.by) { const [x, y, w, h] = blockRectPx(mbx, mby, md.bw, md.bh); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); n++; } + } + } + const [x, y, w, h] = blockRectPx(a.bx, a.by, bw, bh); + ctx.fillStyle = `rgba(${col},0.08)`; ctx.fillRect(x, y, w, h); + if (mode === 'cut') { + // red hatching: this will be taken down + ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip(); + ctx.strokeStyle = `rgba(${col},0.35)`; ctx.lineWidth = Math.max(1, zp * 0.25); + const step = Math.max(8, zp * 2.5); + ctx.beginPath(); for (let k = -h; k < w; k += step) { ctx.moveTo(x + k, y + h); ctx.lineTo(x + k + h, y); } ctx.stroke(); + ctx.restore(); + } + ctx.strokeStyle = `rgba(${col},0.98)`; ctx.lineWidth = Math.max(1.5, zp * (mode === 'cut' ? 0.45 : 0.35)); + ctx.setLineDash([zp * 1.2, zp * 0.8]); ctx.lineDashOffset = -(R.frame * 0.4) % (zp * 2); + ctx.strokeRect(x, y, w, h); ctx.setLineDash([]); ctx.lineDashOffset = 0; + ctx.restore(); + const label = mode === 'cut' ? '✂ CUT' : '⧉ COPY'; + const sub = !sd ? 'drag a box' : n ? (mode === 'cut' ? `${plural(n, 'piece')} · let go to take down` : `${plural(n, 'piece')} · let go to copy`) : 'nothing built in here'; + drawTag(label, col, x, y - Math.round(23 * Math.max(1, R.canvas.width / window.innerWidth)), sub); +} +// Pasting: the clipboard follows the cursor, snapped to the grid +function drawPastePreview() { + const ctx = R.ctx, zp = R.zp, bp = GAME.clip, { bx: ox, by: oy } = stampOrigin(bp.bw, bp.bh); + drawGrid(aimBlock().bx, aimBlock().by, 7); + ctx.save(); + const [x, y, w, h] = blockRectPx(ox, oy, bp.bw, bp.bh); + ctx.strokeStyle = 'rgba(92,186,166,0.85)'; ctx.setLineDash([zp, zp]); ctx.lineWidth = Math.max(1, zp * 0.3); + ctx.strokeRect(x, y, w, h); ctx.setLineDash([]); + for (const [dx, dy, code, v] of bp.blocks) { const [bx2, by2, bw2, bh2] = blockRectPx(ox + dx, oy + dy, 1, 1); ctx.fillStyle = `rgba(${previewColor(code, v)},0.5)`; ctx.fillRect(bx2, by2, bw2, bh2); } + for (const [type, dx, dy] of bp.machines) { const P = PIECE_BY_ID[type]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, P.bw, P.bh); ctx.strokeStyle = 'rgba(255,210,122,0.8)'; ctx.strokeRect(mx, my, mw, mh); } + for (const [k, dx, dy] of bp.molds) { const md = MOLDS[k]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, md.bw, md.bh); ctx.strokeStyle = 'rgba(255,190,90,0.8)'; ctx.strokeRect(mx, my, mw, mh); } + ctx.restore(); + drawTag('PASTE', '92,186,166', x, y - Math.round(23 * Math.max(1, R.canvas.width / window.innerWidth)), 'click to place · R flips · Esc stops'); +} diff --git a/src/ui/previews.js b/src/ui/previews.js index dd29de3..b612669 100644 --- a/src/ui/previews.js +++ b/src/ui/previews.js @@ -1,4 +1,4 @@ -// Previews drawn under the cursor: build ghosts, drill aim and pulses, selections, paste. +// Cursor previews: shared grid helpers, air streaks and tool preview dispatch. // ---------- Overlay previews ---------- function blockRectPx(bx, by, bw, bh) { const q = worldToScreenPx(bx * BS, by * BS); return [q.x, q.y, bw * BS * R.zp, bh * BS * R.zp]; } function drawGrid(cbx, cby, radius) { @@ -18,109 +18,6 @@ function previewColor(code, v) { if (code === BP_FILTER) return '96,100,110'; return { [BP_BELT]: '110,114,124', [BP_TUBE]: '120,132,150', [BP_SHAFT]: '160,118,70', [BP_GRATE]: '170,90,66', [BP_GATE]: '160,84,64', [BP_PIPE]: '176,112,72', [BP_PUMP]: '104,122,142', [BP_OUTLET]: '170,106,66' }[code] || '120,200,190'; } -// Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking. -// Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines -// run on unbroken from one cell to the next and look like real fractures, not a pattern. -const CRACK_N = [makeNoise(mulberry32(0xc7ac1)), makeNoise(mulberry32(0xc7ac2)), makeNoise(mulberry32(0xc7ac3))]; -const CRACK_LV = [{ s: 1 / 7, k: 0.1, w: 0.2 }, { s: 1 / 3.6, k: 0.42, w: 0.14 }, { s: 1 / 2, k: 0.72, w: 0.09 }]; -function crackSeg(ctx, x, y, n, sc, zp, oy) { - const v = (cx, cy) => n(cx * sc + 0.37, cy * sc + 0.61); - const a = v(x, y), b = v(x + 1, y), c = v(x + 1, y + 1), d = v(x, y + 1); - const pts = []; - if ((a < 0) !== (b < 0)) pts.push(x + a / (a - b), y); - if ((b < 0) !== (c < 0)) pts.push(x + 1, y + b / (b - c)); - if ((d < 0) !== (c < 0)) pts.push(x + d / (d - c), y + 1); - if ((a < 0) !== (d < 0)) pts.push(x, y + a / (a - d)); - for (let k = 0; k + 3 < pts.length; k += 4) { - ctx.moveTo((pts[k] - R.camX) * zp, (pts[k + 1] - R.camY) * zp + oy); - ctx.lineTo((pts[k + 2] - R.camX) * zp, (pts[k + 3] - R.camY) * zp + oy); - } -} -function drawCracks() { - const ctx = R.ctx, zp = R.zp; - if (zp < 2.5) return; - const x0 = Math.max(0, Math.floor(R.camX)), y0 = Math.max(0, Math.floor(R.camY)); - const x1 = Math.min(W - 1, Math.ceil(R.camX + R.canvas.width / zp)), y1 = Math.min(H - 1, Math.ceil(R.camY + R.canvas.height / zp)); - const cells = []; - for (let y = y0; y <= y1; y++) { const row = y * W; for (let x = x0; x <= x1; x++) { const dp = digp[row + x]; if (dp && PH[mat[row + x]] === PH_SOLID) cells.push(x, y, dp / 255); } } - if (!cells.length) return; - ctx.save(); ctx.lineCap = 'round'; - for (let l = 0; l < CRACK_LV.length; l++) { - const L = CRACK_LV[l]; - // a soft lit lip just below each crack, then the dark crack itself - for (const pass of [0, 1]) { - ctx.beginPath(); - for (let k = 0; k < cells.length; k += 3) if (cells[k + 2] >= L.k) crackSeg(ctx, cells[k], cells[k + 1], CRACK_N[l], L.s, zp, pass ? 0 : Math.max(1, zp * 0.12)); - ctx.strokeStyle = pass ? `rgba(18,12,10,${0.78 - l * 0.12})` : 'rgba(255,240,220,0.13)'; - ctx.lineWidth = Math.max(1, zp * L.w * (pass ? 1 : 1.3)); - ctx.stroke(); - } - } - ctx.restore(); -} -// The pulses in flight: a wavefront running out from the face of the bot, fading as it goes -function pulseRGB(tune) { - if (!tune) return '150,215,255'; - return `${Math.round((COLR[tune] + 255) / 2)},${Math.round((COLG[tune] + 255) / 2)},${Math.round((COLB[tune] + 255) / 2)}`; -} -// screen points across a fan (mouth at the bot, or out at distance sd along each ray) -function fanPoints(ox, oy, ang, Wd, sd, n) { - const pts = []; - for (let k = 0; k <= n; k++) { const r = pulseRay(ox, oy, ang, Wd, -1 + 2 * k / n); pts.push(worldToScreenPx(r.x + r.ux * sd, r.y + r.uy * sd)); } - return pts; -} -function drawDrillPulses() { - if (!drillPulses.length) return; - const ctx = R.ctx, zp = R.zp; - ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; - for (const q of drillPulses) { - const Wd = PULSE_W[q.w], rr = Math.min(q.r, q.range), a = Math.max(0, 1 - q.r / (q.range + 3)), col = pulseRGB(q.tune); - if (rr < 3) continue; - const front = fanPoints(q.ox, q.oy, q.ang, Wd, rr, 10), back = fanPoints(q.ox, q.oy, q.ang, Wd, Math.max(2.5, rr - 3), 10); - const line = (pts) => { ctx.beginPath(); pts.forEach((pt, k) => k ? ctx.lineTo(pt.x, pt.y) : ctx.moveTo(pt.x, pt.y)); }; - // a faint band behind the front, then the bright front itself - ctx.fillStyle = `rgba(${col},${(0.08 * a).toFixed(3)})`; - line(front); for (let k = back.length - 1; k >= 0; k--) ctx.lineTo(back[k].x, back[k].y); ctx.closePath(); ctx.fill(); - ctx.strokeStyle = `rgba(${col},${(0.2 * a).toFixed(3)})`; ctx.lineWidth = Math.max(3, zp * 1.3); line(front); ctx.stroke(); - ctx.strokeStyle = `rgba(${col},${(0.85 * a).toFixed(3)})`; ctx.lineWidth = Math.max(1.5, zp * 0.3); line(front); ctx.stroke(); - } - ctx.restore(); -} -// With the drill in hand: the fan a pulse will sweep (as wide as the bot where it leaves it), -// out to where it stops, and a crosshair -function drawDrillAim(ax, ay) { - const ctx = R.ctx, zp = R.zp; - const o = drillOrigin(), c = worldToScreenPx(o.x, o.y), p = worldToScreenPx(ax + 0.5, ay + 0.5); - const d = Math.hypot(ax + 0.5 - o.x, ay + 0.5 - o.y), ang = Math.atan2(ay + 0.5 - o.y, ax + 0.5 - o.x); - const Wd = PULSE_W[GAME.drillW], range = Math.max(3, Math.min(drillReach(GAME.drillW), d + 3)); - const ok = inDrillReach(ax, ay, GAME.drillW), col = ok ? pulseRGB(GAME.drillTune) : '239,106,85'; - const mouth = fanPoints(o.x, o.y, ang, Wd, 2.5, 1), far = fanPoints(o.x, o.y, ang, Wd, range, 10); - ctx.save(); - // the outline gets a dark under-stroke so it reads on bright sky as well as dark rock - const outline = () => { ctx.beginPath(); ctx.moveTo(mouth[0].x, mouth[0].y); for (const pt of far) ctx.lineTo(pt.x, pt.y); ctx.lineTo(mouth[1].x, mouth[1].y); ctx.closePath(); }; - ctx.fillStyle = `rgba(${col},0.06)`; outline(); ctx.fill(); - ctx.setLineDash([zp * 0.8, zp * 0.8]); ctx.lineDashOffset = -(R.frame * 0.3) % (zp * 1.6); - ctx.strokeStyle = 'rgba(10,14,20,0.28)'; ctx.lineWidth = Math.max(2, zp * 0.3); ctx.stroke(); - ctx.strokeStyle = `rgba(${col},0.6)`; ctx.lineWidth = Math.max(1, zp * 0.14); ctx.stroke(); - ctx.setLineDash([]); ctx.lineDashOffset = 0; - // the emitter glows across the bot's face while it fires - const hot = Math.max(0, 1 - (tick - lastPulseTick) / PULSE_EVERY); - if (GAME.lmb && hot > 0) { - ctx.globalCompositeOperation = 'lighter'; - ctx.strokeStyle = `rgba(${col},${(0.7 * hot).toFixed(3)})`; ctx.lineWidth = Math.max(2, zp * 0.6); ctx.lineCap = 'round'; - ctx.beginPath(); ctx.moveTo(mouth[0].x, mouth[0].y); ctx.lineTo(mouth[1].x, mouth[1].y); ctx.stroke(); - const g = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, zp * 3.5); - g.addColorStop(0, `rgba(${col},${(0.35 * hot).toFixed(3)})`); g.addColorStop(1, `rgba(${col},0)`); - ctx.fillStyle = g; ctx.fillRect(c.x - zp * 3.5, c.y - zp * 3.5, zp * 7, zp * 7); - ctx.globalCompositeOperation = 'source-over'; - } - ctx.strokeStyle = ok ? 'rgba(239,230,216,0.85)' : 'rgba(239,106,85,0.85)'; ctx.lineWidth = Math.max(1, zp * 0.2); - const k = zp * 0.9; - ctx.beginPath(); ctx.moveTo(p.x - k * 1.6, p.y); ctx.lineTo(p.x - k * 0.5, p.y); ctx.moveTo(p.x + k * 0.5, p.y); ctx.lineTo(p.x + k * 1.6, p.y); - ctx.moveTo(p.x, p.y - k * 1.6); ctx.lineTo(p.x, p.y - k * 0.5); ctx.moveTo(p.x, p.y + k * 0.5); ctx.lineTo(p.x, p.y + k * 1.6); ctx.stroke(); - if (GAME.drillTune) { const sz = Math.max(6, zp * 1.4); ctx.fillStyle = rgbOf(GAME.drillTune); ctx.fillRect(p.x + k * 1.8, p.y - k * 1.8 - sz, sz, sz); ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.strokeRect(p.x + k * 1.8 + 0.5, p.y - k * 1.8 - sz + 0.5, sz - 1, sz - 1); } - ctx.restore(); -} // Air swirls into the pickup ring or spreads from the nozzle toward the cursor. function drawAirStreaks(ax, ay, p, r, blowing) { if (!SET.particles || (SET.fog && fogSeen(ax, ay) < 64)) return; @@ -157,6 +54,11 @@ function drawAirStreaks(ax, ay, p, r, blowing) { } ctx.restore(); } +function drawReachCircle(radius, alpha) { + const ctx = R.ctx, zp = R.zp, c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2); + ctx.strokeStyle = `rgba(239,106,85,${alpha})`; ctx.setLineDash([zp * 2, zp * 2]); ctx.lineWidth = Math.max(1, zp * 0.3); + ctx.beginPath(); ctx.arc(c.x, c.y, radius * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); +} function drawPreviews() { const ctx = R.ctx, zp = R.zp; const t = toolId(), ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y); @@ -165,14 +67,10 @@ function drawPreviews() { const reachOk = inReach(ax, ay); const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld(), pasting = !sel && GAME.paste && GAME.clip; if (!reachOk && t !== 'drill' && !sel) { - const c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2); - ctx.strokeStyle = 'rgba(239,106,85,0.35)'; ctx.setLineDash([zp * 2, zp * 2]); ctx.lineWidth = Math.max(1, zp * 0.3); - ctx.beginPath(); ctx.arc(c.x, c.y, REACH * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); + drawReachCircle(REACH, 0.35); } if (t === 'drill' && !sel && !pasting && !inDrillReach(ax, ay, GAME.drillW)) { - const c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2); - ctx.strokeStyle = 'rgba(239,106,85,0.45)'; ctx.setLineDash([zp * 2, zp * 2]); ctx.lineWidth = Math.max(1, zp * 0.3); - ctx.beginPath(); ctx.arc(c.x, c.y, drillReach(GAME.drillW) * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); + drawReachCircle(drillReach(GAME.drillW), 0.45); } drawDrillPulses(); if (t === 'drill' && !sel && !pasting) drawDrillAim(ax, ay); @@ -251,7 +149,7 @@ function drawPreviews() { ctx.fillRect(p.x + zp * 6, p.y - zp * 6 - 14, tw + 10, 18); ctx.fillStyle = '#e7dccb'; ctx.fillText(label, p.x + zp * 6 + 5, p.y - zp * 6); } } else { - const bw = P.kind === 'mold' ? MOLDS[P.mold].bw : P.bw, bh = P.kind === 'mold' ? MOLDS[P.mold].bh : P.bh; + const { bw, bh } = pieceSize(P); const { bx, by } = stampOrigin(bw, bh); const err = P.kind === 'mold' ? canPlaceMold(P.mold, bx, by) : canPlaceMachine(P.id, bx, by); const swap = !!err && GAME.replace && /already/.test(err) && !footprintBlocker(bx, by, bw, bh); @@ -290,82 +188,3 @@ function drawPreviews() { 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); } -// a small labelled tag drawn on the world (screen pixels) -function drawTag(text, col, x, y, sub) { - const ctx = R.ctx, u = Math.max(1, R.canvas.width / window.innerWidth); - if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace'; - ctx.save(); - ctx.font = `700 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.textBaseline = 'middle'; - const tw = ctx.measureText(text).width; - ctx.font = `500 ${Math.round(11 * u)}px ${R.monoFont}`; - const sw = sub ? ctx.measureText(sub).width + 8 * u : 0; - const h = Math.round(19 * u), pad = 7 * u, w = tw + sw + pad * 2; - x = Math.max(2, Math.min(R.canvas.width - w - 2, x)); y = Math.max(2, Math.min(R.canvas.height - h - 2, y)); - ctx.fillStyle = 'rgba(14,12,11,0.9)'; ctx.fillRect(x, y, w, h); - ctx.fillStyle = `rgb(${col})`; ctx.fillRect(x, y, 3 * u, h); - ctx.strokeStyle = `rgba(${col},0.8)`; ctx.lineWidth = u; ctx.strokeRect(x + u / 2, y + u / 2, w - u, h - u); - ctx.font = `700 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.fillStyle = `rgb(${col})`; ctx.fillText(text, x + pad, y + h / 2 + u * 0.5); - if (sub) { ctx.font = `500 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.fillStyle = 'rgba(231,220,203,0.9)'; ctx.fillText(sub, x + pad + tw + 8 * u, y + h / 2 + u * 0.5); } - ctx.restore(); -} -// Copy (teal) and cut (red, hatched): the box being dragged, what it would pick up, and a tag -const SEL_COL = { copy: '92,186,166', cut: '239,106,85' }; -function drawSelection(mode, ax, ay) { - const ctx = R.ctx, zp = R.zp, col = SEL_COL[mode], sd = GAME.selDrag; - const sx = sd ? sd.x : ax, sy = sd ? sd.y : ay; - const a = blockOf(Math.min(sx, ax), Math.min(sy, ay)), c = blockOf(Math.max(sx, ax), Math.max(sy, ay)); - const bw = c.bx - a.bx + 1, bh = c.by - a.by + 1; - drawGrid(blockOf(ax, ay).bx, blockOf(ax, ay).by, 7); - ctx.save(); - // what is inside: built blocks, and machines and molds that fit wholly inside - let n = 0; - if (sd) { - ctx.fillStyle = `rgba(${col},${mode === 'cut' ? 0.42 : 0.3})`; - for (let by = a.by; by <= c.by; by++) for (let bx = a.bx; bx <= c.bx; bx++) { - if (bx < 0 || by < 0 || bx >= BW || by >= BH) continue; - const b = by * BW + bx; - if (blockP[b] && blockIntegrity(b) >= 8) { const [x, y, w, h] = blockRectPx(bx, by, 1, 1); ctx.fillRect(x, y, w, h); n++; } - } - ctx.strokeStyle = `rgba(${col},0.95)`; ctx.lineWidth = Math.max(1, zp * 0.35); - for (const mm of machines) { - if (!mm || PIECE_BY_ID[mm.type].fixed) continue; - const P = PIECE_BY_ID[mm.type], mbx = mm.x / BS, mby = mm.y / BS; - if (mbx >= a.bx && mby >= a.by && mbx + P.bw - 1 <= c.bx && mby + P.bh - 1 <= c.by) { const [x, y, w, h] = blockRectPx(mbx, mby, P.bw, P.bh); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); n++; } - } - for (const mo of molds) { - const md = MOLDS[mo.kind], mbx = mo.x / BS, mby = mo.y / BS; - if (mbx >= a.bx && mby >= a.by && mbx + md.bw - 1 <= c.bx && mby + md.bh - 1 <= c.by) { const [x, y, w, h] = blockRectPx(mbx, mby, md.bw, md.bh); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); n++; } - } - } - const [x, y, w, h] = blockRectPx(a.bx, a.by, bw, bh); - ctx.fillStyle = `rgba(${col},0.08)`; ctx.fillRect(x, y, w, h); - if (mode === 'cut') { - // red hatching: this will be taken down - ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip(); - ctx.strokeStyle = `rgba(${col},0.35)`; ctx.lineWidth = Math.max(1, zp * 0.25); - const step = Math.max(8, zp * 2.5); - ctx.beginPath(); for (let k = -h; k < w; k += step) { ctx.moveTo(x + k, y + h); ctx.lineTo(x + k + h, y); } ctx.stroke(); - ctx.restore(); - } - ctx.strokeStyle = `rgba(${col},0.98)`; ctx.lineWidth = Math.max(1.5, zp * (mode === 'cut' ? 0.45 : 0.35)); - ctx.setLineDash([zp * 1.2, zp * 0.8]); ctx.lineDashOffset = -(R.frame * 0.4) % (zp * 2); - ctx.strokeRect(x, y, w, h); ctx.setLineDash([]); ctx.lineDashOffset = 0; - ctx.restore(); - const label = mode === 'cut' ? '✂ CUT' : '⧉ COPY'; - const sub = !sd ? 'drag a box' : n ? (mode === 'cut' ? `${plural(n, 'piece')} · let go to take down` : `${plural(n, 'piece')} · let go to copy`) : 'nothing built in here'; - drawTag(label, col, x, y - Math.round(23 * Math.max(1, R.canvas.width / window.innerWidth)), sub); -} -// Pasting: the clipboard follows the cursor, snapped to the grid -function drawPastePreview() { - const ctx = R.ctx, zp = R.zp, bp = GAME.clip, { bx: ox, by: oy } = stampOrigin(bp.bw, bp.bh); - drawGrid(aimBlock().bx, aimBlock().by, 7); - ctx.save(); - const [x, y, w, h] = blockRectPx(ox, oy, bp.bw, bp.bh); - ctx.strokeStyle = 'rgba(92,186,166,0.85)'; ctx.setLineDash([zp, zp]); ctx.lineWidth = Math.max(1, zp * 0.3); - ctx.strokeRect(x, y, w, h); ctx.setLineDash([]); - for (const [dx, dy, code, v] of bp.blocks) { const [bx2, by2, bw2, bh2] = blockRectPx(ox + dx, oy + dy, 1, 1); ctx.fillStyle = `rgba(${previewColor(code, v)},0.5)`; ctx.fillRect(bx2, by2, bw2, bh2); } - for (const [type, dx, dy] of bp.machines) { const P = PIECE_BY_ID[type]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, P.bw, P.bh); ctx.strokeStyle = 'rgba(255,210,122,0.8)'; ctx.strokeRect(mx, my, mw, mh); } - for (const [k, dx, dy] of bp.molds) { const md = MOLDS[k]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, md.bw, md.bh); ctx.strokeStyle = 'rgba(255,190,90,0.8)'; ctx.strokeRect(mx, my, mw, mh); } - ctx.restore(); - drawTag('PASTE', '92,186,166', x, y - Math.round(23 * Math.max(1, R.canvas.width / window.innerWidth)), 'click to place · R flips · Esc stops'); -} diff --git a/src/ui/selection-panels.js b/src/ui/selection-panels.js new file mode 100644 index 0000000..1aef2d6 --- /dev/null +++ b/src/ui/selection-panels.js @@ -0,0 +1,90 @@ +// Selection panels: current tool card and bag materials. +function renderSelCard() { + const t = toolId(), c = $('selcard'); + let h = ''; + const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld(); + c.hidden = false; + const ng = ghostBlocks.size + ghostMachines.length + ghostMolds.length; + if (sel === 'cut') { + h = `
✂ CutHold X and drag a box
+
Let go to take down everything you built in the box (refunded to your bag) and pick it up to paste somewhere else. Ctrl+Z puts it back.
`; + } else if (sel === 'copy') { + h = `
⧉ CopyHold C and drag a box
+
Let go to copy what you built in the box. Then click to place ghosts of it; your bot builds them from your bag.
`; + } else if (GAME.paste && GAME.clip) { + h = `
PasteBlueprint${GAME.clip.bw}×${GAME.clip.bh} blocks
+
Click to place a ghost; your bot builds ghosts from your bag when it is close. R flips it. Right click or Esc stops pasting, V starts again.
Needs ${esc(costText(bpCost(GAME.clip), 1) || 'nothing')}
`; + if (ng) h += `
${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.
`; + } else if (t === 'drill') { + const b = BIT_TIERS[player.bitTier]; + const pw = PULSE_W[GAME.drillW], tn = GAME.drillTune; + h = `
QResonance drill${pw.name} pulseE
+
${esc(b.name.toLowerCase())}hardness ≤ ${b.hard}reach ${drillReach(GAME.drillW)}${tn ? `tuned: ${esc(MD[tn].name.toLowerCase())} · R` : 'untuned · R tunes'}
+
Hold to send pulses at the cursor: rock they shake hard enough shatters and falls. Q/E width: narrow bores a long tunnel fast, wide clears a big face up close. R over a material tunes to it, so only that shatters. Built blocks are never harmed.
`; + } else if (t === 'build') { + // the build tool in general; the piece itself is described in the menu's hover popup + const P = piece(); + const chips = buildVariantChips(P) + (GAME.replace ? 'Replace · G' : '') + + (ng ? `${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.` : ''); + h = '
Build
' + + (chips ? `
${chips}
` : '') + + '
Place pieces from the menu below. Drag for a line, Shift-drag for a box; out of reach becomes ghosts.
' + + '
Q/E piece · Shift+Q/E tab · R turn or material · F material menu · hold G replace · right click take back · hold C/X copy/cut · V paste · Ctrl+Z undo
'; + } else if (t === 'vacuum') { + const all = blowingAll(), list = all ? blowAllList() : blowList(); + const sws = ms => ms.slice(0, 4).map(m => ``).join('') + (ms.length > 4 ? `+${ms.length - 4}` : ''); + const count = list.reduce((n, m) => n + bag[m], 0); + h = `
Pneumatic nozzlewidth ${GAME.brush[1]}
+
Pick up${GAME.blow.size ? 'Selected' : 'What you click'} · Shift: everything${sws(GAME.blow.size ? [...GAME.blow] : GAME.lmb && GAME.vacFilter > 0 ? [GAME.vacFilter] : [])}
+
Blow outQ${all ? '' : sws(list)}${all ? list.length ? `Everything · mix of ${list.length}` : 'Everything · nothing loose' : list.length > 1 ? `Mix of ${list.length}` : list.length ? esc(MD[list[0]].name) : GAME.blow.size ? 'none in bag' : 'nothing selected'}${count ? count.toLocaleString('en-US') : 'none'}E
+
Left picks up the selection (or what you click if nothing is selected), right blows it out. Hold Ctrl in the bag to pick from everything you've found. Shift: everything.
`; + } else if (t === 'ignite') { + h = `
Igniterup to 640°C
Hold on fuel to light it. Coal catches around 420°C, wood around 300°C. Fuel needs open air next to it.
`; + } + if (c.innerHTML !== h) c.innerHTML = h; +} +let lastBagKey = ''; +function renderBag() { + const cap = bagCap(), pct = Math.min(100, bagTotal / cap * 100); + $('bagcap').textContent = `${bagTotal.toLocaleString('en-US')} / ${cap.toLocaleString('en-US')}`; + $('bagbar').firstChild.style.width = pct + '%'; + $('bagbar').classList.toggle('full', bagTotal >= cap); + const t = toolId(), all = blowingAll(), blowing = all ? blowAllList() : [...GAME.blow]; + const missing = [...GAME.blow].filter(m => bag[m] <= 0); + if (GAME.ctrl && t === 'vacuum') for (const m of BAG_ORDER) if (seen[m] && bag[m] <= 0 && !GAME.blow.has(m) && (PH[m] === PH_POWDER || PH[m] === PH_ITEM || PH[m] === PH_LIQUID && player.flask)) missing.push(m); + const l = bagMaterials().concat(missing); + const key = l.map(m => m + ':' + bag[m] + (GAME.blow.has(m) ? 'b' : '')).join(',') + '|' + GAME.wallMat + '|' + GAME.filterCfg.m.join('.') + '|' + t + '|' + GAME.pieceIdx + '|' + all + '|' + (t === 'vacuum' && GAME.ctrl) + '|' + missing.join('.'); + if (key === lastBagKey) return; + lastBagKey = key; + const box = $('baglist'); box.innerHTML = ''; + // the hint and Clear sit under the list, outside its scroll, so they never scroll away + const foot = $('bagfoot'); foot.innerHTML = ''; + if (t === 'vacuum') { + foot.appendChild(el('div', { class: 'baghint' }, "Click: select only this · Ctrl+click: add to the selection (hold Ctrl to see everything you've found) · Q/E: next")); + if (GAME.blow.size) { + const clear = el('button', { class: 'bagclear', 'data-bag-clear': '' }, 'Clear selection'); + clear.onclick = () => { GAME.blow.clear(); lastBagKey = ''; hudDirty = true; playSfx('ui'); }; + foot.appendChild(clear); + } + } + if (!l.length) { box.appendChild(el('div', { class: 'empty' }, 'Empty. Break rock with the Drill, then use the Pneumatic nozzle.')); return; } + for (const m of l) { + if (m === missing[0]) box.appendChild(el('div', { class: 'bagsep' }, 'Not in bag')); + const sel = (t === 'vacuum' && blowing.includes(m)) || (t === 'build' && m === GAME.wallMat && pieceUsesWall(piece())) || (t === 'build' && piece().filter && GAME.filterCfg.m.includes(m)); + const row = el('div', { class: 'bagrow' + (bag[m] > 0 ? '' : ' none') + (sel ? ' on' : '') }); + const b = el('button', { class: 'pick', title: 'Select ' + MD[m].name }); + b.innerHTML = `${esc(MD[m].name)}${bag[m] > 0 ? bag[m].toLocaleString('en-US') : '–'}`; + // Ctrl+click adds to or removes from the selection. GAME.ctrl too: on some Linux setups the click + // arrives without ctrlKey even though the Ctrl keydown (which opened the full list) was seen. + b.oncontextmenu = e => e.preventDefault(); + b.onclick = e => { + if (e.ctrlKey || e.metaKey || GAME.ctrl) { toggleBlow(m); if (t !== 'vacuum') selectTool(1); return; } + if (t === 'build' && piece().filter && filterable(m)) { toggleFilterMat(m); return; } + else if (t === 'build' && WALL_OF[m]) { GAME.wallMat = m; if (!pieceUsesWall(piece())) GAME.pieceIdx = 0; } + else { GAME.blow = GAME.blow.size === 1 && GAME.blow.has(m) ? new Set() : new Set([m]); if (t !== 'vacuum') selectTool(1); } + hudDirty = true; lastBagKey = ''; playSfx('ui'); + }; + row.appendChild(b); + box.appendChild(row); + } +} diff --git a/src/ui/selection-pickers.js b/src/ui/selection-pickers.js new file mode 100644 index 0000000..e2cb051 --- /dev/null +++ b/src/ui/selection-pickers.js @@ -0,0 +1,59 @@ +// Selection pickers: filter rules, filter settings and wall materials. +// What a filter can hold: loose things and bars you have found, plus anything it already holds +function filterChoices(cur) { return BAG_ORDER.filter(m => filterable(m) && (seen[m] || cur.includes(m))); } +function matList(ms) { + const n = ms.map(m => MD[m].name.toLowerCase()); + return n.length <= 3 ? n.join(n.length === 2 ? ' and ' : ', ') : `${n.slice(0, 2).join(', ')} and ${n.length - 2} more`; +} +// What a filter does, in words: what falls through and what rides along +function filterParts(f) { + if (!f.m.length) return f.inv ? { drops: 'everything', rides: 'nothing' } : { drops: 'nothing', rides: 'everything' }; + return f.inv ? { drops: 'everything else', rides: matList(f.m) } : { drops: matList(f.m), rides: 'everything else' }; +} +function filterSentence(f) { const p = filterParts(f); return `drop ${p.drops} and carry ${p.rides}`; } +// Change the whole run of matching filters at once +function applyFilterRun(run, f) { + for (const b of run) { blockV[b] = f.dir ? 128 : 0; setFilter(b, f); } + filtersDirty = true; +} +function openFilterPicker(cur, onDone, title) { + const sel = new Set(cur.m); + let inv = cur.inv ? 1 : 0, dir = cur.dir ? 1 : 0; + const list = filterChoices(cur.m); + const body = `
+ + +
+
Rollers turn
` + + (list.length ? `
${list.map(m => ``).join('')}
` : '

Find something loose first.

') + + `

`; + dialog(title || 'Filter', body, [{ label: 'Clear', cls: 'ghost', fn: () => { sel.clear(); sync(); return true; } }, { label: 'Cancel' }, { label: 'Set filter', cls: 'primary', fn: () => { onDone({ m: [...sel].sort((a, b) => a - b), inv, dir }); hudDirty = true; } }]); + const box = $('dlg-body'); + function sync() { + box.querySelectorAll('[data-inv]').forEach(b => { const on = +b.dataset.inv === inv; b.classList.toggle('on', on); b.setAttribute('aria-checked', on); }); + box.querySelectorAll('[data-dir]').forEach(b => { const on = +b.dataset.dir === dir; b.classList.toggle('on', on); b.setAttribute('aria-checked', on); }); + box.querySelectorAll('[data-m]').forEach(b => { const on = sel.has(+b.dataset.m); b.classList.toggle('on', on); b.setAttribute('aria-checked', on); }); + const f = { m: [...sel].sort((a, b) => a - b), inv }; + box.querySelector('#fp-sum').textContent = `${title && /in this row/.test(title) ? 'These filters' : 'This filter'} will ${filterSentence(f)}, ${dir ? 'right' : 'left'}.`; + } + box.querySelectorAll('[data-dir]').forEach(b => b.onclick = () => { dir = +b.dataset.dir; playSfx('ui'); sync(); }); + box.querySelectorAll('[data-inv]').forEach(b => b.onclick = () => { inv = +b.dataset.inv; playSfx('ui'); sync(); }); + box.querySelectorAll('[data-m]').forEach(b => b.onclick = () => { const m = +b.dataset.m; if (sel.has(m)) sel.delete(m); else sel.add(m); playSfx('ui'); sync(); }); + sync(); +} +function openWallPicker() { + const P = piece(), title = P.kind === 'mold' ? `${P.name} material` : 'Wall material'; + const list = WALL_LIST.filter(m => bag[m] > 0).concat(WALL_LIST.filter(m => !bag[m])); + const body = `
${list.map(m => { + const d = MD[WALL_OF[m]], cost = pieceCostNow(P, m), on = m === GAME.wallMat; + // two sources build the same wall (glass from shards or from bars): say which + const name = WALL_LIST.filter(k => WALL_OF[k] === WALL_OF[m]).length > 1 ? `${d.name} from ${MD[m].name.toLowerCase()}` : d.name; + const heat = d.ign ? `burns at ${fmtT(d.ign)}` : d.melt ? `melts at ${fmtT(d.melt)}` : "won't melt"; + return ``; + }).join('')}
`; + dialog(title, body, [{ label: 'Cancel' }]); + $('dlg-body').querySelectorAll('[data-wall]').forEach(b => b.onclick = () => { + GAME.wallMat = +b.dataset.wall; closeModal('m-dialog'); playSfx('ui'); hudDirty = true; lastBagKey = ''; + setStatus(`${P.kind === 'mold' ? P.name : 'Walls'}: ${MD[WALL_OF[GAME.wallMat]].name.toLowerCase()}.`, 'info'); + }); +} diff --git a/src/ui/selection.js b/src/ui/selection.js index ec00934..c477651 100644 --- a/src/ui/selection.js +++ b/src/ui/selection.js @@ -1,4 +1,5 @@ -// The selected tool and piece: R / Q / E, drill width, filter picker, the selection card, the bag panel. +// Tool selection: cycling, drill tuning, piece variants and costs. +function pieceSize(P) { return P.kind === 'mold' ? MOLDS[P.mold] : P; } function pieceUsesWall(P) { return P.variants === 'wall'; } function setDrillWidth(w) { const n = Math.max(0, Math.min(PULSE_W.length - 1, w)); @@ -86,64 +87,6 @@ function pieceV(P) { return GAME.pieceDir[P.id] || 0; } function pieceExtra(P) { return P.filter ? { m: GAME.filterCfg.m.slice(), inv: GAME.filterCfg.inv } : undefined; } -// What a filter can hold: loose things and bars you have found, plus anything it already holds -function filterChoices(cur) { return BAG_ORDER.filter(m => filterable(m) && (seen[m] || cur.includes(m))); } -function matList(ms) { - const n = ms.map(m => MD[m].name.toLowerCase()); - return n.length <= 3 ? n.join(n.length === 2 ? ' and ' : ', ') : `${n.slice(0, 2).join(', ')} and ${n.length - 2} more`; -} -// What a filter does, in words: what falls through and what rides along -function filterParts(f) { - if (!f.m.length) return f.inv ? { drops: 'everything', rides: 'nothing' } : { drops: 'nothing', rides: 'everything' }; - return f.inv ? { drops: 'everything else', rides: matList(f.m) } : { drops: matList(f.m), rides: 'everything else' }; -} -function filterSentence(f) { const p = filterParts(f); return `drop ${p.drops} and carry ${p.rides}`; } -// Change the whole run of matching filters at once -function applyFilterRun(run, f) { - for (const b of run) { blockV[b] = f.dir ? 128 : 0; setFilter(b, f); } - filtersDirty = true; -} -function openFilterPicker(cur, onDone, title) { - const sel = new Set(cur.m); - let inv = cur.inv ? 1 : 0, dir = cur.dir ? 1 : 0; - const list = filterChoices(cur.m); - const body = `
- - -
-
Rollers turn
` - + (list.length ? `
${list.map(m => ``).join('')}
` : '

Find something loose first.

') - + `

`; - dialog(title || 'Filter', body, [{ label: 'Clear', cls: 'ghost', fn: () => { sel.clear(); sync(); return true; } }, { label: 'Cancel' }, { label: 'Set filter', cls: 'primary', fn: () => { onDone({ m: [...sel].sort((a, b) => a - b), inv, dir }); hudDirty = true; } }]); - const box = $('dlg-body'); - function sync() { - box.querySelectorAll('[data-inv]').forEach(b => { const on = +b.dataset.inv === inv; b.classList.toggle('on', on); b.setAttribute('aria-checked', on); }); - box.querySelectorAll('[data-dir]').forEach(b => { const on = +b.dataset.dir === dir; b.classList.toggle('on', on); b.setAttribute('aria-checked', on); }); - box.querySelectorAll('[data-m]').forEach(b => { const on = sel.has(+b.dataset.m); b.classList.toggle('on', on); b.setAttribute('aria-checked', on); }); - const f = { m: [...sel].sort((a, b) => a - b), inv }; - box.querySelector('#fp-sum').textContent = `${title && /in this row/.test(title) ? 'These filters' : 'This filter'} will ${filterSentence(f)}, ${dir ? 'right' : 'left'}.`; - } - box.querySelectorAll('[data-dir]').forEach(b => b.onclick = () => { dir = +b.dataset.dir; playSfx('ui'); sync(); }); - box.querySelectorAll('[data-inv]').forEach(b => b.onclick = () => { inv = +b.dataset.inv; playSfx('ui'); sync(); }); - box.querySelectorAll('[data-m]').forEach(b => b.onclick = () => { const m = +b.dataset.m; if (sel.has(m)) sel.delete(m); else sel.add(m); playSfx('ui'); sync(); }); - sync(); -} -function openWallPicker() { - const P = piece(), title = P.kind === 'mold' ? `${P.name} material` : 'Wall material'; - const list = WALL_LIST.filter(m => bag[m] > 0).concat(WALL_LIST.filter(m => !bag[m])); - const body = `
${list.map(m => { - const d = MD[WALL_OF[m]], cost = pieceCostNow(P, m), on = m === GAME.wallMat; - // two sources build the same wall (glass from shards or from bars): say which - const name = WALL_LIST.filter(k => WALL_OF[k] === WALL_OF[m]).length > 1 ? `${d.name} from ${MD[m].name.toLowerCase()}` : d.name; - const heat = d.ign ? `burns at ${fmtT(d.ign)}` : d.melt ? `melts at ${fmtT(d.melt)}` : "won't melt"; - return ``; - }).join('')}
`; - dialog(title, body, [{ label: 'Cancel' }]); - $('dlg-body').querySelectorAll('[data-wall]').forEach(b => b.onclick = () => { - GAME.wallMat = +b.dataset.wall; closeModal('m-dialog'); playSfx('ui'); hudDirty = true; lastBagKey = ''; - setStatus(`${P.kind === 'mold' ? P.name : 'Walls'}: ${MD[WALL_OF[GAME.wallMat]].name.toLowerCase()}.`, 'info'); - }); -} function filterBlockAt(x, y) { if (x < 0 || y < 0 || x >= W || y >= H) return -1; const b = ((y / BS) | 0) * BW + ((x / BS) | 0); return blockP[b] === BP_FILTER ? b : -1; } function chipCost(cost) { const ok = canAfford(cost, 1); return `${esc(costText(cost, 1))}`; } function pieceCostNow(P, m = GAME.wallMat) { @@ -151,92 +94,3 @@ function pieceCostNow(P, m = GAME.wallMat) { if (P.kind === 'block' && P.code === BP_WALL) return wallCost(m, BS * BS); return P.cost; } -function renderSelCard() { - const t = toolId(), c = $('selcard'); - let h = ''; - const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld(); - c.hidden = false; - const ng = ghostBlocks.size + ghostMachines.length + ghostMolds.length; - if (sel === 'cut') { - h = `
✂ CutHold X and drag a box
-
Let go to take down everything you built in the box (refunded to your bag) and pick it up to paste somewhere else. Ctrl+Z puts it back.
`; - } else if (sel === 'copy') { - h = `
⧉ CopyHold C and drag a box
-
Let go to copy what you built in the box. Then click to place ghosts of it; your bot builds them from your bag.
`; - } else if (GAME.paste && GAME.clip) { - h = `
PasteBlueprint${GAME.clip.bw}×${GAME.clip.bh} blocks
-
Click to place a ghost; your bot builds ghosts from your bag when it is close. R flips it. Right click or Esc stops pasting, V starts again.
Needs ${esc(costText(bpCost(GAME.clip), 1) || 'nothing')}
`; - if (ng) h += `
${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.
`; - } else if (t === 'drill') { - const b = BIT_TIERS[player.bitTier]; - const pw = PULSE_W[GAME.drillW], tn = GAME.drillTune; - h = `
QResonance drill${pw.name} pulseE
-
${esc(b.name.toLowerCase())}hardness ≤ ${b.hard}reach ${drillReach(GAME.drillW)}${tn ? `tuned: ${esc(MD[tn].name.toLowerCase())} · R` : 'untuned · R tunes'}
-
Hold to send pulses at the cursor: rock they shake hard enough shatters and falls. Q/E width: narrow bores a long tunnel fast, wide clears a big face up close. R over a material tunes to it, so only that shatters. Built blocks are never harmed.
`; - } else if (t === 'build') { - // the build tool in general; the piece itself is described in the menu's hover popup - const P = piece(); - const chips = buildVariantChips(P) + (GAME.replace ? 'Replace · G' : '') - + (ng ? `${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.` : ''); - h = '
Build
' - + (chips ? `
${chips}
` : '') - + '
Place pieces from the menu below. Drag for a line, Shift-drag for a box; out of reach becomes ghosts.
' - + '
Q/E piece · Shift+Q/E tab · R turn or material · F material menu · hold G replace · right click take back · hold C/X copy/cut · V paste · Ctrl+Z undo
'; - } else if (t === 'vacuum') { - const all = blowingAll(), list = all ? blowAllList() : blowList(); - const sws = ms => ms.slice(0, 4).map(m => ``).join('') + (ms.length > 4 ? `+${ms.length - 4}` : ''); - const count = list.reduce((n, m) => n + bag[m], 0); - h = `
Pneumatic nozzlewidth ${GAME.brush[1]}
-
Pick up${GAME.blow.size ? 'Selected' : 'What you click'} · Shift: everything${sws(GAME.blow.size ? [...GAME.blow] : GAME.lmb && GAME.vacFilter > 0 ? [GAME.vacFilter] : [])}
-
Blow outQ${all ? '' : sws(list)}${all ? list.length ? `Everything · mix of ${list.length}` : 'Everything · nothing loose' : list.length > 1 ? `Mix of ${list.length}` : list.length ? esc(MD[list[0]].name) : GAME.blow.size ? 'none in bag' : 'nothing selected'}${count ? count.toLocaleString('en-US') : 'none'}E
-
Left picks up the selection (or what you click if nothing is selected), right blows it out. Hold Ctrl in the bag to pick from everything you've found. Shift: everything.
`; - } else if (t === 'ignite') { - h = `
Igniterup to 640°C
Hold on fuel to light it. Coal catches around 420°C, wood around 300°C. Fuel needs open air next to it.
`; - } - if (c.innerHTML !== h) c.innerHTML = h; -} -let lastBagKey = ''; -function renderBag() { - const cap = bagCap(), pct = Math.min(100, bagTotal / cap * 100); - $('bagcap').textContent = `${bagTotal.toLocaleString('en-US')} / ${cap.toLocaleString('en-US')}`; - $('bagbar').firstChild.style.width = pct + '%'; - $('bagbar').classList.toggle('full', bagTotal >= cap); - const t = toolId(), all = blowingAll(), blowing = all ? blowAllList() : [...GAME.blow]; - const missing = [...GAME.blow].filter(m => bag[m] <= 0); - if (GAME.ctrl && t === 'vacuum') for (const m of BAG_ORDER) if (seen[m] && bag[m] <= 0 && !GAME.blow.has(m) && (PH[m] === PH_POWDER || PH[m] === PH_ITEM || PH[m] === PH_LIQUID && player.flask)) missing.push(m); - const l = bagMaterials().concat(missing); - const key = l.map(m => m + ':' + bag[m] + (GAME.blow.has(m) ? 'b' : '')).join(',') + '|' + GAME.wallMat + '|' + GAME.filterCfg.m.join('.') + '|' + t + '|' + GAME.pieceIdx + '|' + all + '|' + (t === 'vacuum' && GAME.ctrl) + '|' + missing.join('.'); - if (key === lastBagKey) return; - lastBagKey = key; - const box = $('baglist'); box.innerHTML = ''; - // the hint and Clear sit under the list, outside its scroll, so they never scroll away - const foot = $('bagfoot'); foot.innerHTML = ''; - if (t === 'vacuum') { - foot.appendChild(el('div', { class: 'baghint' }, "Click: select only this · Ctrl+click: add to the selection (hold Ctrl to see everything you've found) · Q/E: next")); - if (GAME.blow.size) { - const clear = el('button', { class: 'bagclear', 'data-bag-clear': '' }, 'Clear selection'); - clear.onclick = () => { GAME.blow.clear(); lastBagKey = ''; hudDirty = true; playSfx('ui'); }; - foot.appendChild(clear); - } - } - if (!l.length) { box.appendChild(el('div', { class: 'empty' }, 'Empty. Break rock with the Drill, then use the Pneumatic nozzle.')); return; } - for (const m of l) { - if (m === missing[0]) box.appendChild(el('div', { class: 'bagsep' }, 'Not in bag')); - const sel = (t === 'vacuum' && blowing.includes(m)) || (t === 'build' && m === GAME.wallMat && pieceUsesWall(piece())) || (t === 'build' && piece().filter && GAME.filterCfg.m.includes(m)); - const row = el('div', { class: 'bagrow' + (bag[m] > 0 ? '' : ' none') + (sel ? ' on' : '') }); - const b = el('button', { class: 'pick', title: 'Select ' + MD[m].name }); - b.innerHTML = `${esc(MD[m].name)}${bag[m] > 0 ? bag[m].toLocaleString('en-US') : '–'}`; - // Ctrl+click adds to or removes from the selection. GAME.ctrl too: on some Linux setups the click - // arrives without ctrlKey even though the Ctrl keydown (which opened the full list) was seen. - b.oncontextmenu = e => e.preventDefault(); - b.onclick = e => { - if (e.ctrlKey || e.metaKey || GAME.ctrl) { toggleBlow(m); if (t !== 'vacuum') selectTool(1); return; } - if (t === 'build' && piece().filter && filterable(m)) { toggleFilterMat(m); return; } - else if (t === 'build' && WALL_OF[m]) { GAME.wallMat = m; if (!pieceUsesWall(piece())) GAME.pieceIdx = 0; } - else { GAME.blow = GAME.blow.size === 1 && GAME.blow.has(m) ? new Set() : new Set([m]); if (t !== 'vacuum') selectTool(1); } - hudDirty = true; lastBagKey = ''; playSfx('ui'); - }; - row.appendChild(b); - box.appendChild(row); - } -}