Single-click select, multi-select modifiers, glued radial menu#22
Single-click select, multi-select modifiers, glued radial menu#22jasonkneen wants to merge 5 commits into
Conversation
Introduce a prototype radial object menu and add first-class support for bespoke custom voxel objects. Changes include: - Add engine/world/33-radial-menu.js: new DOM radial menu with color, size, rotate, move, duplicate and drill-down color subring; exposes tickRadialMenu() for per-frame positioning and actions. - Wire tickRadialMenu into the animation loop (25-animation-loop-schema.js) and include the new script in tiny-world-builder.html. - Add CSS for the radial menu in styles/tiny-world.css. - Expand world schema to support customParts/customName/customPart so AI-authored bespoke objects can be described as arrays of low-poly primitives. - Add materializeCustomPartCells in persistence (29-persistence-api.js) to convert cell.customParts into registered voxel build stamps and rewrite cells to reference voxelBuildId before normalization/validation; call this during applyState/applyStatePatch. - Update AI object/enhancement code (21-object-transform-voxel-build.js, 26-ai-generation.js) to avoid defaulting to Japanese-themed seeds, broaden the enhancement trigger regex, allow creation of brand-new objects (not just enhancement), and instruct the scene/primitive assembler to propose and place hero/landmark objects that can be converted to bespoke customParts later. These changes enable the editor to accept AI-generated custom objects, materialize them into stamps, and provide a quick radial UI for common object actions.
Add support for loading a world from the ?world URL param during boot (sanitized same-origin URL): show a placeholder scene immediately, reset camera defaults, and fetch+swap in the remote world when it arrives. Update the radial menu to recognize island selections: restrict actions to those supported by island transforms (move/rotate), apply rotation directly to the selected island (and call applyEditableIslandTransform if present), and rebuild the root ring when selection type changes. Update publish.sh to copy a local data/ directory into dist/data so same-origin world JSON can be published. Also add fork-improvements-report.md documenting fork findings and recommended lifts.
Introduce lightweight storage/toast utilities (twToast, twSafeSetItem, twDownloadJSON, twPickJSONFile) to surface localStorage errors and handle safe saves/downloads. Embed/export/import support for custom voxel-build stamps and asset templates (referencedVoxelBuildStamps, registerEmbeddedVoxelBuildStamps, exportAssetLibrary, importAssetLibrary), and persist embedded custom builds when saving worlds. Add an 8-slot island placement workflow: snapping hologram slots, nearest-slot selection, hover/placement integration, and an on-tool-select snap. Replace several direct localStorage.setItem calls with twSafeSetItem and tidy save payload handling. Also adjust ghost generation shadow flags so tile plates neither cast nor receive shadows. Minor wiring for UI menu items and cross-module island helpers exposed on window.
Unify and harden editable-island behavior, crowd routing, mooring routing, and related UI/tooling. Key changes: - Model/engines: align engine placement offsets, use shared lift-engine system for home/islands, preserve original rocket engine as 'heavy', adjust propeller shader tint/alpha and prop scales, and wire home engine build (buildHomeIslandEngines) so home engines are selectable/upgradeable. - Editable islands: render island terrain per-cell (parity with home), add ensureEditableIslandCellTiles, and adjust selection rendering so editable-island cells always render correctly. - Crowd/AI: expand walkable terrain set, introduce path-cell detection and a BFS grid pathfinder with segment/check/simplify utilities, implement path-biased wander routes (favor roads but roam land), and spawn logic that prefers paths while avoiding obstacles. - Moorings: add hazard avoidance when routing cables (MOORING_HAZARD_CLEARANCE and avoidMooringHazards) so cables route around engine hazards instead of failing; remove previous blocker rejection for engine hazards. - World logic & tools: fix neighbor-level checks to respect island-local GRID bounds, make island-placement anchor fallback to home, ensure new islands are created unselected (select:false), detect occupied visual slots, hide island ghost when no free slot, and add a menu item to clear all sky-islands. - UI: reveal the agent panel when engine targets are selected so controls are visible. - Assets: add three JPEG assets (home-default-view.jpeg, island-percell-terrain.jpeg, jet-original.jpeg). These changes improve parity between home and editable islands, prevent routing/placement failures, and make crowd motion robust around obstacles.
Selection (Select tool): plain click selects one object and shows the radial menu; Shift+click extends a contiguous range from the last anchor; Cmd/Ctrl+Shift+click toggles individual cells (discontiguous); empty click clears. Shift+drag marquee unchanged. Wires up the previously-unused toggleCellSelection helper. Radial menu: fix jitter during camera moves. updateCamera() never refreshed matrixWorldInverse, so the menu projected against a one-frame-stale camera while orbiting. Call camera.updateMatrixWorld() before projecting and position via subpixel translate3d instead of snapped left/top.
❌ Deploy Preview for tiny-world-builder failed.
|
📝 WalkthroughWalkthroughThis PR introduces an 8-slot grid-based sky island placement system with radial context menu, completely overhauls crowd navigation with tile-based walkability and BFS pathfinding, implements custom voxel-part materialization with URL-based world loading, and adds comprehensive client-side utilities for storage and file I/O. It also refines AI prompts for custom object generation and includes fork-improvement analysis. ChangesIsland Placement and Editable Island System
Radial Context Menu System
Crowd Navigation Walkability and Pathfinding Overhaul
Persistence, Custom Objects, and Asset Management
Documentation and Build Infrastructure
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint skipped: no ESLint configuration detected in root package.json. To enable, add Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
This PR expands selection/radial-menu behavior, but the actual diff also includes broader changes to island placement, persistence, AI/schema support, crowd routing, asset export/import, and generated documentation.
Changes:
- Adds plain-click selection, modifier multi-select behavior, and a new radial DOM menu anchored to the transform gizmo.
- Adds custom voxel-build embedding/import/export and
?world=URL loading support. - Includes wider island, crowd, schema, rendering, and generated-report changes beyond the stated PR scope.
Reviewed changes
Copilot reviewed 23 out of 26 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
tiny-world-builder.html |
Loads the new radial menu module. |
styles/tiny-world.css |
Adds radial-menu styles and minimap touch handling. |
engine/world/33-radial-menu.js |
Implements the new radial object action menu. |
engine/world/20-input-place-erase.js |
Updates click selection and island placement interaction logic. |
engine/world/19-tools-toolbar.js |
Hooks island-tool selection into default slot hologram behavior. |
engine/world/18-scene-pick-xr.js |
Adds island hologram rendering support. |
engine/world/12-selection-tool.js |
Changes editable-island cell mesh rendering behavior. |
engine/world/14-editable-islands-moorings.js |
Adds home-island engine handling, per-cell island terrain, and mooring hazard routing. |
engine/world/16-drop-anim-adjacency.js |
Adjusts island adjacency bounds for riser/water-fall behavior. |
engine/world/15-ghost-generation-fade.js |
Changes ghost terrain shadow handling. |
engine/world/11-vehicle-crowd.js |
Reworks crowd walkability and routing. |
engine/world/09-model-stamp-loader.js |
Updates island engine visuals and home engine integration. |
engine/world/08-voxel-stamp-renderer.js |
Routes custom voxel stamp saves through safe storage. |
engine/world/00-prelude.js |
Adds toast, safe localStorage, JSON download, and JSON picker helpers. |
engine/world/21-object-transform-voxel-build.js |
Updates object-enhancement seeds and AI prompts. |
engine/world/25-animation-loop-schema.js |
Ticks the radial menu and extends embedded schema with custom parts. |
engine/world/26-ai-generation.js |
Updates generation prompts and validation coverage. |
engine/world/28-generate-panel-agent.js |
Reveals the agent panel for engine controls. |
engine/world/29-persistence-api.js |
Adds custom voxel-build materialization/embedding and world URL loading. |
engine/world/30-ui-boot-wiring.js |
Adds world URL boot handling and asset library import/export menu items. |
publish.sh |
Copies optional data/ files into dist. |
fork-improvements-report.md |
Adds fork-analysis documentation. |
.codesurf/DREAMING.md |
Replaces generated workspace memory content. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "customParts": { | ||
| "$ref": "#/$defs/customParts" |
| top.title = level === 'root' ? 'Close' : 'Back'; | ||
| top.addEventListener('click', e => { | ||
| e.stopPropagation(); | ||
| if (level === 'root') { if (typeof clearSelection === 'function') clearSelection(); } |
| function ensureHomeIslandObject() { | ||
| if (homeIslandRef) return homeIslandRef; | ||
| homeIslandRef = { | ||
| id: 'home', __home: true, | ||
| boardX: 0, boardZ: 0, | ||
| positionX: 0, positionY: 0, positionZ: 0, rotationY: 0, | ||
| engines: (typeof defaultEditableIslandEngineStates === 'function') ? defaultEditableIslandEngineStates() : [], | ||
| baseGroup: null, group: null, contentGroup: null, lod: 'full', | ||
| }; | ||
| editableIslandById.set('home', homeIslandRef); |
| [assistant turn failed before producing content] | ||
|
|
||
| _Generated: 2026-05-29 (eighteenth pass)_ | ||
| ### Session: Article generator cron run | ||
| - Source: OpenClaw | ||
| - Provider: openclaw (main) | ||
| - Updated: 2026-05-30T07:41:19.879Z | ||
|
|
||
| --- | ||
| USER: You are the VibeClaw Article Generator... |
| # Fork Improvement Harvest — tiny-world-builder | ||
| _Scope: forks pushed in the last week (since 2026-05-23). Only forks genuinely AHEAD of upstream were analyzed: limudim972/main (168 commits ahead, merge-base 2026-05-14) and yuxiaoli/develop (5 commits ahead, merge-base 2026-05-28). Other 10 recently-touched forks had zero original commits._ |
There was a problem hiding this comment.
Actionable comments posted: 13
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@engine/world/00-prelude.js`:
- Around line 61-69: The toast suppression logic in the save-failure path uses
"__twStorageWarnedAt" and "now" so the first failure is mistakenly suppressed
when __twStorageWarnedAt starts at 0; update the condition in the block that
computes "now" (and currently checks "if (now - __twStorageWarnedAt > 8000)") to
also allow the toast when __twStorageWarnedAt === 0 (for example: "if
(__twStorageWarnedAt === 0 || now - __twStorageWarnedAt > 8000)") and continue
to set "__twStorageWarnedAt = now" and call "twToast(...)" as before.
- Around line 32-47: When creating the toast host (__twToastHost) ensure you
reset the cached host to null if attaching it to document.body fails so future
toasts will retry creating/attaching a fresh element; specifically, in the
try/catch block that creates and appends __twToastHost (and the code that
appends child to __twToastHost), catch any error from document.body.appendChild
(or from the overall toast creation) and set __twToastHost = null before exiting
the catch so a detached element is not reused by subsequent calls to the toast
logic.
In `@engine/world/11-vehicle-crowd.js`:
- Around line 991-994: The jitter helper crowdJitterCell can push border cells
outside the valid range; modify crowdJitterCell to clamp the resulting x and z
to the board bounds (0 to GRID-1) after adding random jitter, and then replace
the ad-hoc spawn jitter at the spawn site with a call to this same
crowdJitterCell so both uses share the clamping logic (referencing
crowdJitterCell and the GRID constant).
- Around line 1114-1122: The spawn selection reuses the same path cells when
paths.length < count, causing stacked starts; change the selection in the spawn
loop (where seed is chosen) to prefer unique unused starts by first taking all
path cells, then appending the remaining walkable cells that are not in paths,
and draw seeds from that ordered list (or track used indices) so each spawn uses
a unique cell until that list is exhausted before cycling; update the logic
around collectCrowdWalkableCells(), spawnCells, walkable, paths and the seed
computation used for crowdRouteAround() to ensure uniqueness until all
candidates are used.
In `@engine/world/14-editable-islands-moorings.js`:
- Around line 213-223: ensureEditableIslandCellTiles currently uses
island.cellTilesRendered and never invalidates when GRID changes, leaving stale
meshes; update the function to track the GRID used for the rendered cell tiles
(e.g. add island._cellGridSize) and invalidate when it differs: if
island.cellTilesRendered && island._cellGridSize === GRID return; if
island.cellTilesRendered && island._cellGridSize !== GRID call
disposeEditableIslandSurface(island) and set island.cellTilesRendered = false
before re-rendering; after rendering set island.cellTilesRendered = true and
island._cellGridSize = GRID so future calls correctly short-circuit only when
the GRID matches.
- Around line 64-74: ensureHomeIslandObject currently returns the cached
homeIslandRef after clearEditableIslands() may have emptied editableIslandById,
leaving the map missing the 'home' entry; update ensureHomeIslandObject so that
if homeIslandRef is already truthy it still ensures
editableIslandById.set('home', homeIslandRef) before returning (i.e.,
re-register the cached object), and keep the existing creation path (which
already sets the map) unchanged; reference ensureHomeIslandObject,
homeIslandRef, editableIslandById, and clearEditableIslands in your change.
In `@engine/world/20-input-place-erase.js`:
- Around line 919-921: The branch that falls back to legacy place/erase should
use the pointerDownHit press snapshot instead of currentHover because
setHoverFromCell clears currentHover when the island tool has no free slot;
replace the conditional and call to applyToolToCell(currentHover) so it resolves
the target cell from pointerDownHit's press snapshot (e.g., pointerDownHit.cell
or pointerDownHit.pressSnapshot) and then call applyToolToCell with that
resolved cell; keep the rest of the logic intact and ensure you reference
pointerDownHit, setHoverFromCell, currentHover, and applyToolToCell when making
the change.
In `@engine/world/26-ai-generation.js`:
- Around line 415-423: The transform validation block in
engine/world/26-ai-generation.js (within the cells[i].transform checks) is
incomplete: ensure array-form transforms are validated element-wise (verify
transform is Array of length 3 or 4 and that every element is a number) and
ensure object-form transforms validate all numeric fields (check rotationY,
offsetX, offsetY, offsetZ existings are numbers); update the error returns to
reference the specific failing cell (e.g., 'cells['+i+'].transform[index]
invalid' for array elements and 'cells['+i+'].transform.offsetY invalid' etc.)
so malformed transforms are rejected before proceeding.
In `@engine/world/29-persistence-api.js`:
- Around line 175-180: The loop that pushes embedded stamps currently skips
stamps whose id already exists (getVoxelBuildStamp) causing wrong cross-device
resolution; instead, when normalizeVoxelBuildStamp yields a stamp but
getVoxelBuildStamp(stamp.id) returns a different existing stamp, generate a
fresh unique id for the imported stamp, update stamp.id to that new id, push the
new stamp into VOXEL_BUILD_STAMPS and increment added, and then rewrite all
references in data.cells (appearance.voxelBuildId) that pointed to the original
embedded id to point to the new id before applyState(...) runs; locate this
logic around normalizeVoxelBuildStamp, getVoxelBuildStamp, VOXEL_BUILD_STAMPS
and ensure reference rewriting happens prior to calling applyState.
- Around line 145-151: The code currently deletes c.customParts and c.customName
before calling normalizeVoxelBuildStamp, so if normalization throws the authored
data is lost; change the flow in the loop that handles parts/c to only call
delete c.customParts and delete c.customName after a successful stamp is
obtained: move those delete statements to after stamp is truthy (i.e., after the
try/catch and after the if (!stamp) check passes), ensure
normalizeVoxelBuildStamp({ name, customParts: parts, custom: true, footprint:
2.0 }, 'Custom Object') is invoked with the original c.customParts intact, and
leave c.customParts/c.customName untouched when normalization fails so the
object is preserved or can be handled later.
In `@engine/world/30-ui-boot-wiring.js`:
- Around line 1717-1723: The success toast is shown unconditionally after
calling twDownloadJSON, causing a false "Exported…" message when the download
fails; change the call to only display twToast('Exported …') after
twDownloadJSON reports success by checking its return (or awaiting its Promise)
or by using its success callback, and ensure any thrown/rejected errors are
caught/handled so the success toast is not emitted on failure; target the
twDownloadJSON invocation and the subsequent twToast call to implement this
conditional success behavior.
In `@engine/world/33-radial-menu.js`:
- Around line 107-111: The click handler for buttons currently calls
runAction(b.action) and runAction reads window.event.shiftKey; update the
btn.addEventListener('click', e => { ... }) to call runAction(b.action, e) and
change runAction to accept the event parameter (e.g., runAction(action, evt))
and use evt.shiftKey for the "size" action branch instead of window.event;
update any internal callers of runAction (if any) to provide a suitable event or
null and guard evt before accessing shiftKey.
In `@styles/tiny-world.css`:
- Line 4122: The CSS uses the keyword with incorrect casing: in the rule setting
stroke: currentColor; (look for the stroke declaration in the tiny-world.css
rule around where stroke is defined) change the value to the lowercase keyword
"currentcolor" to satisfy the value-keyword-case lint rule and prevent lint
failures.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 52f378d6-c1e7-4f9e-8137-e46fd44a9500
⛔ Files ignored due to path filters (3)
home-default-view.jpegis excluded by!**/*.jpegisland-percell-terrain.jpegis excluded by!**/*.jpegjet-original.jpegis excluded by!**/*.jpeg
📒 Files selected for processing (23)
.codesurf/DREAMING.mdengine/world/00-prelude.jsengine/world/08-voxel-stamp-renderer.jsengine/world/09-model-stamp-loader.jsengine/world/11-vehicle-crowd.jsengine/world/12-selection-tool.jsengine/world/14-editable-islands-moorings.jsengine/world/15-ghost-generation-fade.jsengine/world/16-drop-anim-adjacency.jsengine/world/18-scene-pick-xr.jsengine/world/19-tools-toolbar.jsengine/world/20-input-place-erase.jsengine/world/21-object-transform-voxel-build.jsengine/world/25-animation-loop-schema.jsengine/world/26-ai-generation.jsengine/world/28-generate-panel-agent.jsengine/world/29-persistence-api.jsengine/world/30-ui-boot-wiring.jsengine/world/33-radial-menu.jsfork-improvements-report.mdpublish.shstyles/tiny-world.csstiny-world-builder.html
| if (!__twToastHost) { | ||
| __twToastHost = document.createElement('div'); | ||
| __twToastHost.style.cssText = 'position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:99999;display:flex;flex-direction:column;gap:8px;pointer-events:none;'; | ||
| document.body.appendChild(__twToastHost); | ||
| } | ||
| const el = document.createElement('div'); | ||
| const bg = kind === 'err' ? '#c0392b' : (kind === 'ok' ? '#2e7d32' : '#333'); | ||
| el.style.cssText = 'pointer-events:auto;max-width:min(92vw,420px);padding:10px 14px;border-radius:8px;color:#fff;font:500 13px/1.4 system-ui,sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.25);background:' + bg + ';opacity:0;transition:opacity .18s ease;'; | ||
| el.textContent = String(message); | ||
| __twToastHost.appendChild(el); | ||
| requestAnimationFrame(() => { el.style.opacity = '1'; }); | ||
| setTimeout(() => { | ||
| el.style.opacity = '0'; | ||
| setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el); }, 220); | ||
| }, kind === 'err' ? 6000 : 3000); | ||
| } catch (_) { /* DOM not ready — nothing we can do */ } |
There was a problem hiding this comment.
Reset the cached toast host when body attachment fails.
If Line 35 throws before the node is attached, __twToastHost still points at a detached element. Every later toast then renders off-DOM, so save/import failures stop surfacing for the rest of the session.
Proposed fix
- if (!__twToastHost) {
- __twToastHost = document.createElement('div');
- __twToastHost.style.cssText = 'position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:99999;display:flex;flex-direction:column;gap:8px;pointer-events:none;';
- document.body.appendChild(__twToastHost);
+ if (!__twToastHost || !__twToastHost.isConnected) {
+ const host = document.createElement('div');
+ host.style.cssText = 'position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:99999;display:flex;flex-direction:column;gap:8px;pointer-events:none;';
+ document.body.appendChild(host);
+ __twToastHost = host;
}
@@
- } catch (_) { /* DOM not ready — nothing we can do */ }
+ } catch (_) {
+ __twToastHost = null;
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (!__twToastHost) { | |
| __twToastHost = document.createElement('div'); | |
| __twToastHost.style.cssText = 'position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:99999;display:flex;flex-direction:column;gap:8px;pointer-events:none;'; | |
| document.body.appendChild(__twToastHost); | |
| } | |
| const el = document.createElement('div'); | |
| const bg = kind === 'err' ? '#c0392b' : (kind === 'ok' ? '#2e7d32' : '#333'); | |
| el.style.cssText = 'pointer-events:auto;max-width:min(92vw,420px);padding:10px 14px;border-radius:8px;color:#fff;font:500 13px/1.4 system-ui,sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.25);background:' + bg + ';opacity:0;transition:opacity .18s ease;'; | |
| el.textContent = String(message); | |
| __twToastHost.appendChild(el); | |
| requestAnimationFrame(() => { el.style.opacity = '1'; }); | |
| setTimeout(() => { | |
| el.style.opacity = '0'; | |
| setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el); }, 220); | |
| }, kind === 'err' ? 6000 : 3000); | |
| } catch (_) { /* DOM not ready — nothing we can do */ } | |
| if (!__twToastHost || !__twToastHost.isConnected) { | |
| const host = document.createElement('div'); | |
| host.style.cssText = 'position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:99999;display:flex;flex-direction:column;gap:8px;pointer-events:none;'; | |
| document.body.appendChild(host); | |
| __twToastHost = host; | |
| } | |
| const el = document.createElement('div'); | |
| const bg = kind === 'err' ? '`#c0392b`' : (kind === 'ok' ? '`#2e7d32`' : '`#333`'); | |
| el.style.cssText = 'pointer-events:auto;max-width:min(92vw,420px);padding:10px 14px;border-radius:8px;color:`#fff`;font:500 13px/1.4 system-ui,sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.25);background:' + bg + ';opacity:0;transition:opacity .18s ease;'; | |
| el.textContent = String(message); | |
| __twToastHost.appendChild(el); | |
| requestAnimationFrame(() => { el.style.opacity = '1'; }); | |
| setTimeout(() => { | |
| el.style.opacity = '0'; | |
| setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el); }, 220); | |
| }, kind === 'err' ? 6000 : 3000); | |
| } catch (_) { | |
| __twToastHost = null; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@engine/world/00-prelude.js` around lines 32 - 47, When creating the toast
host (__twToastHost) ensure you reset the cached host to null if attaching it to
document.body fails so future toasts will retry creating/attaching a fresh
element; specifically, in the try/catch block that creates and appends
__twToastHost (and the code that appends child to __twToastHost), catch any
error from document.body.appendChild (or from the overall toast creation) and
set __twToastHost = null before exiting the catch so a detached element is not
reused by subsequent calls to the toast logic.
| const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); | ||
| if (now - __twStorageWarnedAt > 8000) { | ||
| __twStorageWarnedAt = now; | ||
| const quota = err && (err.name === 'QuotaExceededError' || err.code === 22 || err.code === 1014); | ||
| twToast( | ||
| (label ? label + ' could not be saved' : 'Save failed') + | ||
| (quota ? ' — browser storage is full. Export your assets/worlds to a file to avoid losing them.' : '.'), | ||
| 'err' | ||
| ); |
There was a problem hiding this comment.
Let the first storage failure show a toast.
Line 62 suppresses the first warning on a fresh page because __twStorageWarnedAt starts at 0 and performance.now() is usually still < 8000. That hides the most important save-failure notification.
Proposed fix
- if (now - __twStorageWarnedAt > 8000) {
+ if (!__twStorageWarnedAt || now - __twStorageWarnedAt > 8000) {
__twStorageWarnedAt = now;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); | |
| if (now - __twStorageWarnedAt > 8000) { | |
| __twStorageWarnedAt = now; | |
| const quota = err && (err.name === 'QuotaExceededError' || err.code === 22 || err.code === 1014); | |
| twToast( | |
| (label ? label + ' could not be saved' : 'Save failed') + | |
| (quota ? ' — browser storage is full. Export your assets/worlds to a file to avoid losing them.' : '.'), | |
| 'err' | |
| ); | |
| const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); | |
| if (!__twStorageWarnedAt || now - __twStorageWarnedAt > 8000) { | |
| __twStorageWarnedAt = now; | |
| const quota = err && (err.name === 'QuotaExceededError' || err.code === 22 || err.code === 1014); | |
| twToast( | |
| (label ? label + ' could not be saved' : 'Save failed') + | |
| (quota ? ' — browser storage is full. Export your assets/worlds to a file to avoid losing them.' : '.'), | |
| 'err' | |
| ); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@engine/world/00-prelude.js` around lines 61 - 69, The toast suppression logic
in the save-failure path uses "__twStorageWarnedAt" and "now" so the first
failure is mistakenly suppressed when __twStorageWarnedAt starts at 0; update
the condition in the block that computes "now" (and currently checks "if (now -
__twStorageWarnedAt > 8000)") to also allow the toast when __twStorageWarnedAt
=== 0 (for example: "if (__twStorageWarnedAt === 0 || now - __twStorageWarnedAt
> 8000)") and continue to set "__twStorageWarnedAt = now" and call
"twToast(...)" as before.
| const crowdJitterCell = cell => ({ | ||
| x: cell.x + (Math.random() - 0.5) * 0.22, | ||
| z: cell.z + (Math.random() - 0.5) * 0.22, | ||
| }); |
There was a problem hiding this comment.
Clamp jittered positions at the board edge.
Lines 992-993 can produce x/z < 0 or x/z > GRID - 1 for border cells, so edge walkers occasionally step off the island. Clamp the jitter here, and reuse the same helper for the spawn jitter on Lines 1125-1126.
Proposed fix
+ function crowdJitterPos(x, z, amount = 0.22) {
+ const half = amount * 0.5
+ return {
+ x: Math.max(half, Math.min(GRID - 1 - half, x + (Math.random() - 0.5) * amount)),
+ z: Math.max(half, Math.min(GRID - 1 - half, z + (Math.random() - 0.5) * amount)),
+ }
+ }
+
- const crowdJitterCell = cell => ({
- x: cell.x + (Math.random() - 0.5) * 0.22,
- z: cell.z + (Math.random() - 0.5) * 0.22,
- });
+ const crowdJitterCell = cell => crowdJitterPos(cell.x, cell.z)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const crowdJitterCell = cell => ({ | |
| x: cell.x + (Math.random() - 0.5) * 0.22, | |
| z: cell.z + (Math.random() - 0.5) * 0.22, | |
| }); | |
| function crowdJitterPos(x, z, amount = 0.22) { | |
| const half = amount * 0.5 | |
| return { | |
| x: Math.max(half, Math.min(GRID - 1 - half, x + (Math.random() - 0.5) * amount)), | |
| z: Math.max(half, Math.min(GRID - 1 - half, z + (Math.random() - 0.5) * amount)), | |
| } | |
| } | |
| const crowdJitterCell = cell => crowdJitterPos(cell.x, cell.z) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@engine/world/11-vehicle-crowd.js` around lines 991 - 994, The jitter helper
crowdJitterCell can push border cells outside the valid range; modify
crowdJitterCell to clamp the resulting x and z to the board bounds (0 to GRID-1)
after adding random jitter, and then replace the ad-hoc spawn jitter at the
spawn site with a call to this same crowdJitterCell so both uses share the
clamping logic (referencing crowdJitterCell and the GRID constant).
| const { walkable, paths } = collectCrowdWalkableCells(); | ||
| // Spawn preferentially on paths so crowds start on roads, then wander out. | ||
| const spawnCells = paths.length ? paths : walkable; | ||
| const count = Math.min(crowdCount, walkable.length); | ||
| if (!count) return; | ||
| const characters = ['townie', 'little-girl', 'dad', 'grandfather', 'grandmother']; | ||
| for (let i = 0; i < count; i++) { | ||
| const seed = pathCells[(i * 3) % pathCells.length]; | ||
| const route = crowdRouteAround(seed, pathCells, i); | ||
| const seed = spawnCells[(i * 3) % spawnCells.length]; | ||
| const route = crowdRouteAround(seed, walkable, paths, i); |
There was a problem hiding this comment.
Prefer unique path starts before reusing a cell.
When paths.length < count, Line 1121 cycles back through the same path cells even though walkable still has unused cells. That stacks several people onto the same start tile on sparse-road maps.
Proposed fix
const { walkable, paths } = collectCrowdWalkableCells();
// Spawn preferentially on paths so crowds start on roads, then wander out.
- const spawnCells = paths.length ? paths : walkable;
+ const pathKeys = new Set(paths.map(cell => cell.x + ',' + cell.z))
+ const spawnCells = paths.length
+ ? [...paths, ...walkable.filter(cell => !pathKeys.has(cell.x + ',' + cell.z))]
+ : walkable
const count = Math.min(crowdCount, walkable.length);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@engine/world/11-vehicle-crowd.js` around lines 1114 - 1122, The spawn
selection reuses the same path cells when paths.length < count, causing stacked
starts; change the selection in the spawn loop (where seed is chosen) to prefer
unique unused starts by first taking all path cells, then appending the
remaining walkable cells that are not in paths, and draw seeds from that ordered
list (or track used indices) so each spawn uses a unique cell until that list is
exhausted before cycling; update the logic around collectCrowdWalkableCells(),
spawnCells, walkable, paths and the seed computation used for crowdRouteAround()
to ensure uniqueness until all candidates are used.
| function ensureHomeIslandObject() { | ||
| if (homeIslandRef) return homeIslandRef; | ||
| homeIslandRef = { | ||
| id: 'home', __home: true, | ||
| boardX: 0, boardZ: 0, | ||
| positionX: 0, positionY: 0, positionZ: 0, rotationY: 0, | ||
| engines: (typeof defaultEditableIslandEngineStates === 'function') ? defaultEditableIslandEngineStates() : [], | ||
| baseGroup: null, group: null, contentGroup: null, lod: 'full', | ||
| }; | ||
| editableIslandById.set('home', homeIslandRef); | ||
| return homeIslandRef; |
There was a problem hiding this comment.
Re-register homeIslandRef after the island maps are cleared.
After clearEditableIslands() wipes editableIslandById, this function returns the cached homeIslandRef without putting it back into the map. Home engine picks/upgrades then stop resolving after clear/reset or any home-border rebuild.
Suggested fix
function ensureHomeIslandObject() {
- if (homeIslandRef) return homeIslandRef;
- homeIslandRef = {
- id: 'home', __home: true,
- boardX: 0, boardZ: 0,
- positionX: 0, positionY: 0, positionZ: 0, rotationY: 0,
- engines: (typeof defaultEditableIslandEngineStates === 'function') ? defaultEditableIslandEngineStates() : [],
- baseGroup: null, group: null, contentGroup: null, lod: 'full',
- };
- editableIslandById.set('home', homeIslandRef);
+ if (!homeIslandRef) {
+ homeIslandRef = {
+ id: 'home', __home: true,
+ boardX: 0, boardZ: 0,
+ positionX: 0, positionY: 0, positionZ: 0, rotationY: 0,
+ engines: (typeof defaultEditableIslandEngineStates === 'function') ? defaultEditableIslandEngineStates() : [],
+ baseGroup: null, group: null, contentGroup: null, lod: 'full',
+ };
+ }
+ editableIslandById.set('home', homeIslandRef);
return homeIslandRef;
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@engine/world/14-editable-islands-moorings.js` around lines 64 - 74,
ensureHomeIslandObject currently returns the cached homeIslandRef after
clearEditableIslands() may have emptied editableIslandById, leaving the map
missing the 'home' entry; update ensureHomeIslandObject so that if homeIslandRef
is already truthy it still ensures editableIslandById.set('home', homeIslandRef)
before returning (i.e., re-register the cached object), and keep the existing
creation path (which already sets the map) unchanged; reference
ensureHomeIslandObject, homeIslandRef, editableIslandById, and
clearEditableIslands in your change.
| if (!Array.isArray(parts) || !parts.length) { if (c) { delete c.customParts; delete c.customName; } continue; } | ||
| const name = (typeof c.customName === 'string' && c.customName.trim()) ? c.customName.trim() : 'Custom Object'; | ||
| delete c.customParts; | ||
| delete c.customName; | ||
| let stamp = null; | ||
| try { stamp = normalizeVoxelBuildStamp({ name, customParts: parts, custom: true, footprint: 2.0 }, 'Custom Object'); } catch (_) {} | ||
| if (!stamp) continue; |
There was a problem hiding this comment.
Only strip customParts after stamp creation succeeds.
Lines 147-150 delete the only authored custom-object data before normalizeVoxelBuildStamp(...) is known to succeed. If normalization fails, the load continues with the object silently dropped instead of preserved or rejected.
Proposed fix
const parts = c.customParts;
if (!Array.isArray(parts) || !parts.length) { if (c) { delete c.customParts; delete c.customName; } continue; }
const name = (typeof c.customName === 'string' && c.customName.trim()) ? c.customName.trim() : 'Custom Object';
- delete c.customParts;
- delete c.customName;
let stamp = null;
try { stamp = normalizeVoxelBuildStamp({ name, customParts: parts, custom: true, footprint: 2.0 }, 'Custom Object'); } catch (_) {}
if (!stamp) continue;
+ delete c.customParts;
+ delete c.customName;
if (typeof VOXEL_BUILD_STAMPS !== 'undefined' && typeof getVoxelBuildStamp === 'function' && !getVoxelBuildStamp(stamp.id)) {
VOXEL_BUILD_STAMPS.push(stamp);
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@engine/world/29-persistence-api.js` around lines 145 - 151, The code
currently deletes c.customParts and c.customName before calling
normalizeVoxelBuildStamp, so if normalization throws the authored data is lost;
change the flow in the loop that handles parts/c to only call delete
c.customParts and delete c.customName after a successful stamp is obtained: move
those delete statements to after stamp is truthy (i.e., after the try/catch and
after the if (!stamp) check passes), ensure normalizeVoxelBuildStamp({ name,
customParts: parts, custom: true, footprint: 2.0 }, 'Custom Object') is invoked
with the original c.customParts intact, and leave c.customParts/c.customName
untouched when normalization fails so the object is preserved or can be handled
later.
| for (const item of list) { | ||
| if (!item || typeof item !== 'object') continue; | ||
| if (item.id && getVoxelBuildStamp(item.id)) continue; | ||
| let stamp = null; | ||
| try { stamp = normalizeVoxelBuildStamp(Object.assign({}, item, { custom: true }), item.name); } catch (_) {} | ||
| if (stamp && !getVoxelBuildStamp(stamp.id)) { VOXEL_BUILD_STAMPS.push(stamp); added++; } |
There was a problem hiding this comment.
Handle embedded stamp ID collisions instead of skipping them.
If Line 177 finds an existing local stamp with the same id but different contents, this load path silently reuses the local asset and the imported world resolves voxelBuildId to the wrong build. That breaks the cross-device portability this PR is adding.
A robust fix needs to remap colliding embedded IDs to fresh ones and rewrite the corresponding appearance.voxelBuildId references in data.cells before applyState(...) consumes them.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@engine/world/29-persistence-api.js` around lines 175 - 180, The loop that
pushes embedded stamps currently skips stamps whose id already exists
(getVoxelBuildStamp) causing wrong cross-device resolution; instead, when
normalizeVoxelBuildStamp yields a stamp but getVoxelBuildStamp(stamp.id) returns
a different existing stamp, generate a fresh unique id for the imported stamp,
update stamp.id to that new id, push the new stamp into VOXEL_BUILD_STAMPS and
increment added, and then rewrite all references in data.cells
(appearance.voxelBuildId) that pointed to the original embedded id to point to
the new id before applyState(...) runs; locate this logic around
normalizeVoxelBuildStamp, getVoxelBuildStamp, VOXEL_BUILD_STAMPS and ensure
reference rewriting happens prior to calling applyState.
| twDownloadJSON('tinyworld-assets.json', { | ||
| tinyworldAssets: 1, | ||
| exportedAt: new Date().toISOString(), | ||
| voxelBuilds, | ||
| assetTemplates, | ||
| }); | ||
| twToast('Exported ' + count + ' asset' + (count === 1 ? '' : 's') + ' → tinyworld-assets.json', 'ok'); |
There was a problem hiding this comment.
Only show the success toast if the export actually started.
twDownloadJSON() already reports failures. As written, a download error produces both the error toast and the “Exported …” success toast, which tells the user their backup succeeded when it did not.
Proposed fix
- twDownloadJSON('tinyworld-assets.json', {
+ if (!twDownloadJSON('tinyworld-assets.json', {
tinyworldAssets: 1,
exportedAt: new Date().toISOString(),
voxelBuilds,
assetTemplates,
- });
+ })) return;
twToast('Exported ' + count + ' asset' + (count === 1 ? '' : 's') + ' → tinyworld-assets.json', 'ok');📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| twDownloadJSON('tinyworld-assets.json', { | |
| tinyworldAssets: 1, | |
| exportedAt: new Date().toISOString(), | |
| voxelBuilds, | |
| assetTemplates, | |
| }); | |
| twToast('Exported ' + count + ' asset' + (count === 1 ? '' : 's') + ' → tinyworld-assets.json', 'ok'); | |
| if (!twDownloadJSON('tinyworld-assets.json', { | |
| tinyworldAssets: 1, | |
| exportedAt: new Date().toISOString(), | |
| voxelBuilds, | |
| assetTemplates, | |
| })) return; | |
| twToast('Exported ' + count + ' asset' + (count === 1 ? '' : 's') + ' → tinyworld-assets.json', 'ok'); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@engine/world/30-ui-boot-wiring.js` around lines 1717 - 1723, The success
toast is shown unconditionally after calling twDownloadJSON, causing a false
"Exported…" message when the download fails; change the call to only display
twToast('Exported …') after twDownloadJSON reports success by checking its
return (or awaiting its Promise) or by using its success callback, and ensure
any thrown/rejected errors are caught/handled so the success toast is not
emitted on failure; target the twDownloadJSON invocation and the subsequent
twToast call to implement this conditional success behavior.
| btn.addEventListener('click', e => { | ||
| e.stopPropagation(); | ||
| flash(btn); | ||
| if (b.submenu) renderLevel(b.submenu); | ||
| else runAction(b.action); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="engine/world/33-radial-menu.js"
echo "== File info =="
wc -l "$FILE" || true
echo
echo "== Search for window.event usage =="
rg -n "window\.event" "$FILE" || true
echo
echo "== Inspect lines ~90-140 =="
nl -ba "$FILE" | sed -n '80,150p'
echo
echo "== Inspect lines ~140-220 =="
nl -ba "$FILE" | sed -n '140,230p'
echo
echo "== Find runAction() definition and call sites =="
rg -n "function runAction|runAction\(" "$FILE"
echoRepository: jasonkneen/tiny-world-builder
Length of output: 349
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="engine/world/33-radial-menu.js"
echo "== Search for window.event usage =="
rg -n "window\.event" "$FILE" || true
echo
echo "== Lines 90-150 (context around click handler) =="
sed -n '90,150p' "$FILE" | cat -n -w3 -s': '
echo
echo "== Lines 150-230 (context around runAction) =="
sed -n '150,230p' "$FILE" | cat -n -w3 -s': '
echo
echo "== Search for runAction definition and call sites =="
rg -n "function runAction|runAction\\(" "$FILE"Repository: jasonkneen/tiny-world-builder
Length of output: 341
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="engine/world/33-radial-menu.js"
python3 - <<'PY'
import itertools, pathlib
path = pathlib.Path("engine/world/33-radial-menu.js")
lines = path.read_text().splitlines()
def show(a,b):
for i in range(a-1,b):
if 0<=i<len(lines):
print(f"{i+1:4d}: {lines[i]}")
else:
break
print("== Lines 90-150 ==")
show(90,150)
print("\n== Lines 150-230 ==")
show(150,230)
PY
echo
echo "== Search for runAction definition and call sites =="
rg -n "function runAction|runAction\\(" "$FILE" || true
echo
echo "== Search for scaleSelectedBoardObject usage =="
rg -n "scaleSelectedBoardObject" "$FILE" || trueRepository: jasonkneen/tiny-world-builder
Length of output: 7409
Avoid window.event in radial menu Shift handling; thread the click event into runAction().
engine/world/33-radial-menu.js calls runAction(b.action) from the click handler (107-112), but runAction() reads window.event.shiftKey for the size action (159-176). Pass the click event and use evt.shiftKey instead.
Suggested fix
- btn.addEventListener('click', e => {
+ btn.addEventListener('click', e => {
e.stopPropagation();
flash(btn);
if (b.submenu) renderLevel(b.submenu);
- else runAction(b.action);
+ else runAction(b.action, e);
});
@@
- function runAction(id) {
+ function runAction(id, evt) {
try {
@@
} else if (id === 'size') {
if (typeof scaleSelectedBoardObject === 'function') {
- scaleSelectedBoardObject((window.event && window.event.shiftKey) ? 0.87 : 1.15);
+ scaleSelectedBoardObject((evt && evt.shiftKey) ? 0.87 : 1.15);
}
} else if (id === 'more' || id === 'style') {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@engine/world/33-radial-menu.js` around lines 107 - 111, The click handler for
buttons currently calls runAction(b.action) and runAction reads
window.event.shiftKey; update the btn.addEventListener('click', e => { ... }) to
call runAction(b.action, e) and change runAction to accept the event parameter
(e.g., runAction(action, evt)) and use evt.shiftKey for the "size" action branch
instead of window.event; update any internal callers of runAction (if any) to
provide a suitable event or null and guard evt before accessing shiftKey.
| width: 21px; | ||
| height: 21px; | ||
| fill: none; | ||
| stroke: currentColor; |
There was a problem hiding this comment.
Lowercase currentcolor to satisfy the existing lint rule.
This line trips value-keyword-case, so lint will keep failing until the keyword is lowercased.
Suggested fix
- stroke: currentColor;
+ stroke: currentcolor;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| stroke: currentColor; | |
| stroke: currentcolor; |
🧰 Tools
🪛 Stylelint (17.12.0)
[error] 4122-4122: Expected "currentColor" to be "currentcolor" (value-keyword-case)
(value-keyword-case)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@styles/tiny-world.css` at line 4122, The CSS uses the keyword with incorrect
casing: in the rule setting stroke: currentColor; (look for the stroke
declaration in the tiny-world.css rule around where stroke is defined) change
the value to the lowercase keyword "currentcolor" to satisfy the
value-keyword-case lint rule and prevent lint failures.
Summary
Improves object selection and fixes the radial popup menu jitter when the camera moves.
Selection (Select tool)
toggleCellSelectionhelper; reuses existingsetRectangleSelection/clearSelection.Radial menu jitter
Root cause:
updateCamera()setscamera.position+lookAtbut never callscamera.updateMatrixWorld(), sotickRadialMenuprojected the menu against the previous frame's camera during orbit, causing it to swim. Fix:camera.updateMatrixWorld()before projecting the gizmo position.translate3d(GPU-composited) instead of snappedleft/top.Files
engine/world/20-input-place-erase.js— pointerdown/pointerup selection state machine +lastSelectionAnchor.engine/world/33-radial-menu.js— matrix refresh + translate3d positioning.styles/tiny-world.css—.radial-menuanchoring.Verification (real app, trusted input events, projection math)
framesUntilVisible: 0).npm run smokepasses.Note
npm run checkfails on a pre-existing mismatch between the embeddedWORLD_SCHEMAandworld.schema.json, unrelated to this change (neither file is touched here; the JS syntax gate passes). Worth a separate fix.Summary by CodeRabbit
Release Notes
New Features
Bug Fixes
Enhancements