Compare commits

...

6 commits

Author SHA1 Message Date
06ab438d7b slim 2026-06-03 18:47:09 +09:00
43b659b64b default paintings 2026-06-03 03:13:14 +09:00
06176fb2d5 update 2026-06-02 23:45:38 +09:00
8ee6a87d44 updated 2026-06-02 21:43:57 +09:00
94571943cf split 2026-06-02 17:19:36 +09:00
a05c031074 update 2026-06-02 16:26:44 +09:00
26 changed files with 9860 additions and 6706 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
*.bak
__pycache__/
*.py[cod]

166
README.md
View file

@ -6,13 +6,21 @@ Browser-only prototype for a shared pixel-art island.
Open `index.html` in a modern browser. No build step or third-party dependency is required.
## Refactor layout
- `app.js` remains the browser bootstrap and main UI coordinator.
- `js/core-utils.js`, `js/state-index.js`, `js/rotation-policy.js`, and `js/lighting.js` hold reusable logic shared by the app.
- `js/editor-actions.js` is lazy-loaded when Pixel Studio opens; the app keeps inline fallbacks for first-use safety.
- Server rotation defaults can be supplied with `server/rotation_policy.json` and `--config`.
- Server policy tests live in `server/test_rotation_worker.py`.
## Current prototype scope
- Draw pixel assets in the in-browser editor.
- Save assets to the local library.
- Save assets to the local collection.
- Place static assets and summon dynamic assets onto the island.
- Dynamic assets default to right-facing on spawn; left-facing is rendered by mirroring during movement only.
- Local voting, hiding, remix/copy edit, and import/export tools are included.
- Local voting, hiding, remix/copy edit, and collection management are included.
## Phase 1 changes
@ -25,7 +33,6 @@ Open `index.html` in a modern browser. No build step or third-party dependency i
- Local save uses a compact Phase 2 envelope in `localStorage`.
- Pixel planes are bbox-cropped and RLE-compressed when this is smaller than raw cropped data.
- Compact asset bundles can be exported separately from world snapshots.
- World snapshots contain placements, dynamic summons, and an asset manifest rather than full asset blobs.
- Local object/asset mutations append small sync events to `eventLog`.
- IndexedDB asset/snapshot cache helpers are included for future server-backed missing-asset fetches.
@ -38,7 +45,6 @@ Open `index.html` in a modern browser. No build step or third-party dependency i
- Dynamic runtime updates are stepped at 15 Hz and capped per frame to avoid spiral-of-death on slow devices.
- Rendering pauses while the browser tab is hidden and resumes on visibility change.
- Tile/object lookups use a small spatial index for inspect, erase, and nearby-target searches.
- Data tab reports visible terrain chunks for quick performance inspection.
## Phase 4 changes
@ -47,20 +53,11 @@ Open `index.html` in a modern browser. No build step or third-party dependency i
- Added Select tool. Drag to select a rectangular area, then drag the selection or use arrow keys/buttons to move it.
- Nudge buttons move the active selection; without a selection they shift the whole drawing.
- Added horizontal/vertical flip, palette-colored outline generation, and clear selection.
- Added editor PNG export/import. PNG import quantizes the image to the current island palette and current canvas size.
- Added keyboard shortcuts: B/E/F/I/L/R/S for tools, Ctrl/Cmd+Z/Y for history, arrows for moving selected pixels, Escape to clear selection.
## Export formats
- **Export full JSON**: human-readable full local state.
- **Export compact**: compact local state suitable for saving/transferring.
- **Export snapshot**: placement state plus asset manifest; requires asset bundles or cache to render fully.
- **Export asset bundle**: compact asset blob list for missing-asset transfer.
## Phase 4 follow-up adjustments
- PNG import now chooses the editor canvas size from the image size. Square 8/16/32/64 PNGs import at their native size; other sizes are fit into the nearest supported square canvas.
- Depth is now three-state: high, normal, and low. High brightens sprite pixels; low darkens them.
- Right-clicking the world canvas clears the current map selection instead of moving or placing the selected asset.
- Dynamic sprites now pause intermittently instead of walking continuously.
@ -72,16 +69,7 @@ Open `index.html` in a modern browser. No build step or third-party dependency i
- Editor schema is now 7.
- Fixed the Advanced Draw depth controls so `Depth / High / Low / Clear` wrap inside the drawer instead of overflowing to the right.
- Added Phase 5 guardrails in the Data tab:
- local asset/object volume counters,
- duplicate-content count,
- orphan placement checks,
- terrain compatibility checks,
- hidden/report counters.
- Added `Validate world` export for pre-server integrity checks.
- Added local object reporting from the selection bubble. Reported objects are hidden locally and stored in a local moderation report log.
- Added `Export moderation report` for review/debug data.
- Added `Clear reports` to clear local report logs without un-hiding already hidden objects.
- Added soft local guardrail limits: 160 assets and 220 world objects. Existing objects can still be moved; new saves/placements are blocked when the local cap is reached.
## Phase 5 follow-up changes
@ -121,7 +109,6 @@ Open `index.html` in a modern browser. No build step or third-party dependency i
- Walking particles are less frequent and use more visibly varied colors.
- Remix always creates a new derivative asset; Edit updates the user's own original asset.
- Advanced Draw now includes configurable particle emitter cells for any asset.
- PNG export/import moved to the Data tab.
- Nudge arrow buttons under the canvas were removed; keyboard arrow nudging remains.
- UI text is larger overall, while palette color-code labels keep their small size.
@ -130,7 +117,7 @@ Open `index.html` in a modern browser. No build step or third-party dependency i
This build adds count-based island rotation.
- Default island display cap: 250 objects.
- The local display cap is adjustable from Data > Visual settings.
- The local display cap is adjustable from Settings.
- Objects are not deleted when the cap is exceeded; the effective oldest objects are omitted from the island display.
- Upvotes delay rotation-out by slot adjustment. Downvotes advance rotation-out.
- Local publish / republish quota: 5 objects per author per rolling hour.
@ -161,12 +148,12 @@ This build does not call a server from the browser. The island display cap is ap
This build separates **Asset** and **PlacementObject** more explicitly.
- Asset: a durable library work. Remix only copies pixels and lineage into the canvas.
- PlacementObject: a temporary island exhibition object. It can rotate out while the Asset remains in Library.
- PlacementObject: a temporary island exhibition object. It can rotate out while the Asset remains in Collection.
- Edit has been removed. To revise a work, remix/copy it, save a new Asset, then delete the old one if desired.
- Publishing requires a prototype local account. The first 24 hours allow 5 public placements/hour; day 2+ allows 10/hour.
- The island exhibition has 250 visible slots: 150 newest slots and 100 random revival slots.
- Upvote rank effect is capped at +50 votes.
- Capacity rotation and extreme downvote rotation use the same user-facing explanation: the island exhibition is full, but the work remains in Library.
- Capacity rotation and extreme downvote rotation use the same user-facing explanation: the island exhibition is full, but the work remains in Collection.
- Server moderation can mark `permanent_hidden` / `violation_hidden`; these are hidden from authors and viewers. Admins can restore them with the Python worker.
- Particles are disabled offscreen/when zoomed out and capped at 200 active particles.
@ -187,3 +174,130 @@ python server/compression_lab.py exported_world.json
```
Use this to compare minified JSON, gzip, zlib, and Brotli when available. Production should use the compact asset codec plus HTTP gzip/Brotli rather than hand-rolled custom compression first.
## Phase 5 Exhibition UI polish follow-up
- Compressed the account/name card so the top HUD uses less vertical space.
- Removed the right-side Pan / Place / Erase HUD; placement is guided through Save + Place and Library actions.
- Renamed the liked codex label to Favorite.
- Removed the Advanced Draw depth Clear button; depth can still be cleared with Shift/right-click.
- Normal Erase now removes Light / Particle / Depth metadata on the first click and removes the pixel color on the next click.
- Light rendering and nearby glow are night-only, with subtle random flicker.
- Remix previews hide the nearby source work while checking placement, and final placement nudges away from the remix source to avoid doubled sprites.
- Depth edge shading now reacts to configured light positions or the current sun/moon direction; object silhouette edges are excluded.
## v8 polish notes
- `Save + Place on Island` now auto-generates a local account when needed. The generated account stores an ID, editable display name, and generated password in local storage; users are prompted to change the password.
- `Check on Island` and `New` were removed from the finish area. Saved works enter an island placement preview with `Place here` and `Back to canvas`.
- Editor Select now clears with right-click. The island also shows the left-click/right-click selection hint.
- Basic draw buttons keep a stable layout even when Advanced is open; advanced tools are placed below them.
- Draw tools now have distinct colors and compact icons.
- Particle editing no longer paints emitter positions. Particle effects emit from the whole sprite, with palette-based color and up/down/left/right direction.
## v9 interaction polish and bug fixes
- Removed automatic remix-source avoidance: remix children can now be placed near their source without being forced to a different tile.
- Animated coastal foam with drift, bob, scale, and opacity pulses.
- Removed Collection/Create buttons from the Pixel Island title bar and added a compact cute menu button for the left drawer.
- Added a confirmation dialog before clearing the entire canvas.
- Palette swatches now handle pointerdown directly so the first palette click is applied reliably.
- Particle right-click no longer disables the particle effect; right-click pans/does nothing, while Shift-click can disable.
- Added particle range controls: select an area, then press “Use Sel”; “Whole” resets emission to the whole sprite.
- Swapped Particle and Depth button order.
- Left/Right canvas side buttons no longer mirror the editing canvas view.
- Fixed dynamic animal placement preview so the sprite is visible before pressing Place here.
### v10 interaction / lighting polish
- Replaced the title-side menu control with a larger bottom `DRAW` button.
- Warmed the evening phase with orange/vermilion sky tint and warm darkness overlay.
- Increased lamp reflection bleed radius and light radius so nearby sprites glow more softly.
- Dynamic sprites now save a canvas marked `Left` by mirroring it into the canonical right-facing asset; runtime facing is driven by the actual movement vector.
- Hidden particle range overlays no longer look like an undeletable blue canvas square; the range overlay is shown only while the Particle tool is active.
- Remix actions are now displayed as a bottom sheet. Report is inside a small Menu section.
- Added teleport-to-remix-source from the selected object sheet.
- Object draw order now uses the rendered bottom/contact line, so lower objects consistently draw in front.
- 8×8 sprites use their whole sprite bounding box for click selection, including transparent cells.
- Coastal foam motion was made more visible.
## v12 lighting/compression notes
- Night light reflections are now rendered per sprite immediately after the sprite, preserving front/back draw order.
- Reflection tint affects both depth and non-depth pixels; depth pixels receive stronger directional response.
- Depth no longer uses light-source response unless the night lighting system is actually active.
- Strong compression direction for weak servers: store indexed palette pixels as bit-packed palette indices plus an alpha bitmask and RLE/LZ on top; avoid PNG/base64 as the canonical server payload.
## v13 compression and remix lineage changes
- Palette index encoding is the canonical pixel format. Each pixel stores `.` for transparent or a one-character palette code.
- Compact export now supports `bp6`, a 6-bit packed plane format. Transparent plus the 62 palette codes fit in one 6-bit value.
- Compact export chooses the smallest representation per pixel plane: cropped raw, cropped RLE, or cropped 6-bit packing.
- Remix delta storage was removed again; remixes are stored as independent packed pixel planes.
- Deleting a source asset now cascades through remix descendants. Their island placements, dynamic summons, votes, hidden flags, and moderation rows are removed together.
- Sync events now include `asset.delete`; applying that event also cascades to remix descendants on the receiver.
## v14 rollback / interaction polish
- Removed remix delta saving and remix-source cascade deletion.
- Particles are cell-based again, like Light and Depth: choose color + direction, then paint emitter cells.
- Selected dynamic sprites bounce too, with a slight deterministic tilt.
- The object speech bubble is kept above the object and away from the DRAW dock.
- Added a compact clock in the upper-right corner.
- Reworked daylight/moonlight shadow transitions so evening fades sun shadows out before night, moon shadows fade in during night, and pre-dawn moon shadows disappear before sunrise.
## v14b startup fix
- Restored `updateSelectedLabel()` so bootstrap no longer throws `ReferenceError`.
## v15 interaction polish
- Restored object speech bubbles above the actual rendered sprite, using sprite draw coordinates instead of tile-center estimates.
- Replaced the numeric clock with a fixed analog day-night clock using a sky-color conic gradient and no numbers.
- Kept selected-object shadows mirrored from the ground line while the sprite jumps.
- Smoothed sky and overlay interpolation across dawn, day, evening, and night.
## v16b hotfix
- Fixed the dark/blank world regression caused by using `source-in` compositing directly on the main world canvas for ship water reflection.
- Ship reflections are now precomposited on an offscreen canvas before drawing, so terrain and sprites are not erased or darkened.
## Phase 6 create-layout / authority prep
- The Create tab editor now follows the tighter canvas-first layout: top metadata row, canvas with a vertical palette, large tool buttons, and a full-width Advanced settings panel.
- The palette ramps were rebuilt into cleaner 4-color rows so hue/lightness progression is easier to read.
- Local browser data uses a new storage key so older palette-corrupted art is discarded automatically.
- Shared-world prep now treats publish, object move, day/night time, and spontaneous dynamic motion as server-authoritative concerns.
- The client can keep pending shared publish/move visuals locally while waiting for server validation, and dynamic runtime motion can interpolate toward server-supplied target positions.
## Phase 6b server-authority / Create tab correction
- CREATE layout now groups the canvas and primary tools in the left column with the palette as the right column, matching the attached design more closely.
- Save schema was bumped to 15. Existing local art data from older palette mappings is intentionally reset and replaced with new seed artwork.
- Added server-side `world.phase` and `dynamic.move` event helpers/tests. Shared clients should treat these events as the source of truth for day/night and autonomous dynamic positions.
- Pending shared object moves now render as temporary local interpolation only; the persistent world state is updated only by server-issued events.
- Depth shading can now use a cursor-local light source on rendered sprites at night.
## Custom update 3: colored local light fix + heritage/masterpiece gallery
- Save schema is now `25`.
- Fixed the local depth-light wash so the affected area no longer collapses into a mostly gray band; local lights now preserve more of the source color.
- Reworked depth local-light behavior so the source itself and nearby outer/depth corners catch the strongest highlights, while distance has a weaker effect on brightness than before.
- Reduced the selection bubble offset so the bubble sits noticeably closer to the top of the selected artwork.
- Added several new large default works themed around masterpieces, heritage, and artifacts: **Smile Portrait**, **Great Wave Panel**, **Sunflower Still Life**, **Rosetta Stela**, **Pharaoh Mask**, **Stone Circle**, **Terracotta Sentinel**.
## Custom update 4: cursor-light smoothing + gray-bloom hotfix
- Save schema is now `26`.
- Fixed the urgent regression where a single local light could wash large parts of a sprite into a gray, low-chroma bloom.
- Reduced local-light body fill on depth sprites so highlights stay concentrated on lit edges / corners instead of flattening the whole silhouette.
- Smoothed cursor-light falloff so in-range / out-of-range transitions are less abrupt.
- Removed the always-on center fallback for the cursor inspection light and softened the visible cursor halo, which eliminates the unwanted blue circular artifact when no pointer light should be shown.
## Custom update 5: asset-light gray fix + temporary global delete + more default works
- Save schema is now `27`.
- Adjusted **asset-mounted local lights** again so they no longer wash large depth sprites into a gray body fill; their effect is concentrated much more strongly on lit edges and corners.
- Fixed the analog clock hand offset (it had been visually about 5 degrees early).
- Added a **temporary** delete override so **all collection works can be deleted** regardless of owner. This is clearly marked in `app.js` with comments and can be reverted by setting `TEMP_ALLOW_DELETE_ALL_WORKS = false`.
- Added many more default works: **Rain Bell Tower**, **Crimson Pagoda**, **Meteor Forge**, **Ink Garden Screen**, **Meadow Totem**, **Twilight Caravan**, **Pepper Fox**, **Glass Manta**, **Festival Drummer**, **Bloom Sprite**.

View file

@ -1,58 +0,0 @@
# Pixel Island Summoner
Browser-only prototype for a shared pixel-art island.
## Run
Open `index.html` in a modern browser. No build step or third-party dependency is required.
## Current prototype scope
- Draw pixel assets in the in-browser editor.
- Save assets to the local library.
- Place static assets and summon dynamic assets onto the island.
- Dynamic assets default to right-facing on spawn; left-facing is rendered by mirroring during movement only.
- Local voting, hiding, remix/copy edit, and import/export tools are included.
## Phase 1 changes
- Asset data is normalized to schema 2+ compatible shape.
- Dynamic left-facing pixels are not stored; `faces.left` is represented as `mirror`.
- Empty depth/light metadata is omitted or normalized.
- Editor tools include Draw, Erase, Fill, Pick, Undo, and Redo.
## Phase 2 changes
- Local save uses a compact Phase 2 envelope in `localStorage`.
- Pixel planes are bbox-cropped and RLE-compressed when this is smaller than raw cropped data.
- Compact asset bundles can be exported separately from world snapshots.
- World snapshots contain placements, dynamic summons, and an asset manifest rather than full asset blobs.
- Local object/asset mutations append small sync events to `eventLog`.
- IndexedDB asset/snapshot cache helpers are included for future server-backed missing-asset fetches.
## Phase 3 changes
- Terrain rendering is chunk-cached instead of using one large terrain canvas.
- Only terrain chunks intersecting the current viewport are drawn.
- Static and dynamic objects outside the viewport margin are culled before sprite generation/sorting.
- Dynamic runtime updates are stepped at 15 Hz and capped per frame to avoid spiral-of-death on slow devices.
- Rendering pauses while the browser tab is hidden and resumes on visibility change.
- Tile/object lookups use a small spatial index for inspect, erase, and nearby-target searches.
- Data tab reports visible terrain chunks for quick performance inspection.
## Phase 4 changes
- Editor schema is now 5.
- Added Line and Rect tools. Rect supports filled rectangles with Shift; right-click erases line/rect cells.
- Added Select tool. Drag to select a rectangular area, then drag the selection or use arrow keys/buttons to move it.
- Nudge buttons move the active selection; without a selection they shift the whole drawing.
- Added horizontal/vertical flip, palette-colored outline generation, and clear selection.
- Added editor PNG export/import. PNG import quantizes the image to the current island palette and current canvas size.
- Added keyboard shortcuts: B/E/F/I/L/R/S for tools, Ctrl/Cmd+Z/Y for history, arrows for moving selected pixels, Escape to clear selection.
## Export formats
- **Export full JSON**: human-readable full local state.
- **Export compact**: compact local state suitable for saving/transferring.
- **Export snapshot**: placement state plus asset manifest; requires asset bundles or cache to render fully.
- **Export asset bundle**: compact asset blob list for missing-asset transfer.

7478
app.js

File diff suppressed because it is too large Load diff

3774
app.js.bak

File diff suppressed because it is too large Load diff

View file

@ -9,41 +9,42 @@
<body>
<div class="app">
<canvas id="worldCanvas" aria-label="Island map"></canvas>
<button id="openEditor" class="edgeAdd" title="Open Pixel Studio">+</button>
<button id="openEditor" class="drawDockButton" type="button" title="Open Pixel Studio"><span>✎ DRAW</span><span id="drawQuotaBadge" class="quotaBadge drawQuotaBadge" aria-live="polite"></span></button>
<div id="placementPreviewBar" class="placementPreviewBar" hidden>
<button id="confirmPreviewPlace" class="primary" type="button">Place here</button>
<button id="backToCanvas" class="secondary" type="button">Back to canvas</button>
<span>Check how this work looks on the island. Works are permanent in Library; island placement is temporary exhibition.</span>
<span>Check how this work looks on the island. Works are permanent in Collection; island placement is temporary exhibition.</span>
</div>
<header class="hud topHud">
<div>
<header class="hud topHud islandTopBar">
<div class="brandBlock">
<div class="logo">Pixel Island</div>
<div class="subline">Local prototype / Draw pixels, summon them to islands.</div>
<div class="subline">A rotating island exhibition for tiny pixel works.</div>
</div>
<label class="authorCard">
<span>Your Name</span>
<input id="authorName" type="text" maxlength="24" value="Local Artist" />
<button id="createAccount" class="miniButton" type="button">Create local account</button>
<small id="accountNote">Account required to publish. Prototype only: name + local timestamp.</small>
</label>
<div class="clockCard" aria-label="world clock">
<div id="phaseLabel" class="phaseLabel">Day</div>
<div class="phaseBar"><span id="phaseBar"></span></div>
<div class="clockHint">10 min = 1 day</div>
<div class="authorCard accountCard utilityControl" aria-label="Local account">
<div class="accountFields">
<label class="accountField">ID
<input id="accountId" type="text" readonly placeholder="auto" />
</label>
<label class="accountField">Name
<input id="authorName" type="text" maxlength="24" value="Local Artist" />
</label>
<label class="accountField">Pass
<input id="accountPass" type="text" maxlength="32" placeholder="auto" />
</label>
</div>
<button id="createAccount" class="miniButton" type="button">Generate account</button>
<small id="accountNote">Publish creates a local account for island publishing.</small>
</div>
</header>
<aside class="hud toolHud">
<button id="modeInspect" class="iconButton active" title="Pan / Inspect">Pan</button>
<button id="modePlace" class="iconButton" title="Summon selected asset">Place</button>
<button id="modeErase" class="iconButton danger" title="Remove asset">Erase</button>
<div class="divider"></div>
<button id="zoomOut" class="iconButton" title="Zoom out"></button>
<button id="zoomIn" class="iconButton" title="Zoom in">+</button>
<button id="resetView" class="iconButton" title="Reset view">Reset</button>
</aside>
<div id="analogClock" class="analogClock" aria-label="Island day-night clock" title="10 min = 1 island day">
<div class="analogClockDial">
<span id="analogClockHand" class="analogClockHand"></span>
</div>
<div id="phaseLabel" class="phaseLabel analogClockLabel">Day</div>
<div class="phaseBar analogPhaseBar" aria-hidden="true"><span id="phaseBar"></span></div>
</div>
<section id="studioDrawer" class="studioDrawer" aria-label="Pixel Studio">
<div class="drawerHead">
@ -55,38 +56,31 @@
</div>
<nav class="tabs" aria-label="Studio tabs">
<button class="tab active" data-tab="draw">Draw</button>
<button class="tab" data-tab="library">Library</button>
<button class="tab" data-tab="data">Data</button>
<button class="tab active" data-tab="draw">Create</button>
<button class="tab" data-tab="library">Collection</button>
<button class="tab" data-tab="settings">Settings</button>
</nav>
<div class="drawerBody">
<section id="tab-draw" class="tabPanel active">
<div class="card editorCard heroEditor">
<div class="quickSetupBar twoCols">
<label class="field">Canvas size
<select id="assetSize">
<option value="8" selected>8×8</option>
<option value="16">16×16</option>
<option value="32">32×32</option>
<option value="64">64×64</option>
</select>
</label>
<label class="field">Role
<select id="assetCategory">
<option value="human">Human</option>
<option value="animal">Animal</option>
<option value="nature" selected>Nature</option>
<option value="building">Building</option>
<option value="ship">Ship</option>
<option value="other">Other</option>
</select>
</label>
<div class="creationMetaRow" aria-label="Work setup">
<input id="assetName" class="metaInput metaName" type="text" maxlength="32" placeholder="Name" aria-label="Name" />
<input id="assetHeight" class="metaInput metaDimension" type="number" min="1" max="64" step="1" value="8" placeholder="H" aria-label="Height" />
<span class="dimensionSeparator" aria-hidden="true">×</span>
<input id="assetWidth" class="metaInput metaDimension" type="number" min="1" max="64" step="1" value="8" placeholder="W" aria-label="Width" />
<select id="assetCategory" class="metaInput metaRole" aria-label="Role" required>
<option value="" disabled selected>Role</option>
<option value="human">Human</option>
<option value="animal">Animal</option>
<option value="bird">Bird</option>
<option value="fish">Fish</option>
<option value="nature">Nature</option>
<option value="building">Building</option>
<option value="ship">Ship</option>
<option value="other">Other</option>
</select>
</div>
<label class="field compactNameField">Name
<input id="assetName" type="text" maxlength="32" placeholder="Tiny bakery, round tree, island pup..." />
</label>
<div class="editorTop compactEditorTop">
<div id="sideSwitcher" class="sideSwitcher" hidden aria-label="Sprite direction">
<button id="editLeft" title="Left-facing sprite">◀ Left</button>
@ -95,52 +89,72 @@
</div>
<div class="paintLayout">
<canvas id="paintCanvas" width="512" height="512" aria-label="Pixel editor"></canvas>
<div class="palettePanel">
<div id="paletteGrid" class="paletteGrid" aria-label="Color palette"></div>
<div class="paintMainColumn">
<canvas id="paintCanvas" width="512" height="512" aria-label="Pixel editor"></canvas>
<div class="palettePanel">
<div id="paletteGrid" class="paletteGrid" aria-label="Color palette"></div>
</div>
</div>
<div class="drawControlPanel">
<div class="toolRow editorToolRow basicEditorTools" aria-label="Basic draw tools">
<button id="toolBrush" class="tool toolBrush active" title="B">Draw</button>
<button id="toolErase" class="tool toolErase" title="E">Erase</button>
<button id="toolFill" class="tool toolFill" title="F">Fill</button>
<button id="toolPick" class="tool toolPick" title="I">Pick</button>
</div>
<div class="toolRow editorUtilityRow">
<button id="toggleAdvanced" class="tool toolAdvanced" type="button"> Advanced</button>
<button id="undoPaint" class="tool toolUndo" type="button" disabled>↶ Undo</button>
<button id="redoPaint" class="tool toolRedo" type="button" disabled>↷ Redo</button>
</div>
<div class="advancedPanel">
<div class="advancedPanelHeader">Advanced draw</div>
<div id="advancedToolGroup" class="advancedToolGroup" hidden>
<div class="toolRow advancedDrawRow">
<button id="toolLine" class="tool toolLine advancedTool" title="L">Line</button>
<button id="toolRect" class="tool toolRect advancedTool" title="R">Rect</button>
<button id="toolSelect" class="tool toolSelect advancedTool" title="S">Select</button>
<button id="toolDoor" class="tool toolDoor advancedTool buildingOnly">Door</button>
<button id="clearPaint" class="tool danger advancedTool">Clear</button>
</div>
<div class="toolRow advancedActionRow">
<button id="outlinePaint" class="tool toolOutline advancedTool" type="button">Outline</button>
<button id="flipHorizontal" class="tool toolFlipH advancedTool" type="button">Flip H</button>
<button id="flipVertical" class="tool toolFlipV advancedTool" type="button">Flip V</button>
<button id="clearSelection" class="tool toolClearSel advancedTool" type="button" disabled>Clear Sel</button>
</div>
</div>
<div class="advancedRow extraAdvancedRow">
<button id="toolLight" class="tool toolLight advancedOnly" hidden>Light</button>
<button id="toolParticle" class="tool toolParticle advancedOnly" hidden>Particle</button>
<button id="toolDepth" class="tool toolDepth advancedOnly" hidden>Depth</button>
<button id="depthHigh" class="tool advancedOnly" type="button" hidden>High</button>
<button id="depthLow" class="tool advancedOnly" type="button" hidden>Low</button>
<div id="particleDirectionWrap" class="particleControl particleControlGroup advancedOnly" hidden>
<label>Direction
<select id="particleDirection">
<option value="up" selected>↑ Up</option>
<option value="down">↓ Down</option>
<option value="left">← Left</option>
<option value="right">→ Right</option>
</select>
</label>
<small id="particleRangeStatus">Particle: paint cells like Light/Depth. Shift/right-click clears a cell.</small>
</div>
</div>
<span id="advancedHint" class="hint" hidden></span>
<input id="paintColor" type="color" value="#6bd06b" hidden />
<div id="editHint" class="hint" hidden></div>
</div>
</div>
</div>
<div class="toolRow editorToolRow">
<button id="toolBrush" class="tool active" title="B">Draw</button>
<button id="toolErase" class="tool" title="E">Erase</button>
<button id="toolFill" class="tool" title="F">Fill</button>
<button id="toolPick" class="tool" title="I">Pick</button>
<button id="toolLine" class="tool" title="L">Line</button>
<button id="toolRect" class="tool" title="R">Rect</button>
<button id="toolSelect" class="tool" title="S">Select</button>
<button id="toolDoor" class="tool buildingOnly">Door</button>
<button id="clearPaint" class="tool danger">Clear</button>
</div>
<div class="toolRow editorHistoryRow">
<button id="undoPaint" class="tool" type="button" disabled>Undo</button>
<button id="redoPaint" class="tool" type="button" disabled>Redo</button>
<button id="outlinePaint" class="tool" type="button">Outline</button>
<button id="flipHorizontal" class="tool" type="button">Flip H</button>
<button id="flipVertical" class="tool" type="button">Flip V</button>
<button id="clearSelection" class="tool" type="button" disabled>Clear Sel</button>
</div>
<div class="advancedRow">
<button id="toggleAdvanced" class="tool" type="button">Advanced Draw</button>
<button id="toolLight" class="tool advancedOnly" hidden>Light</button>
<button id="toolDepth" class="tool advancedOnly" hidden>Depth</button>
<button id="toolParticle" class="tool advancedOnly" hidden>Particle</button>
<button id="depthHigh" class="tool advancedOnly" type="button" hidden>High</button>
<button id="depthLow" class="tool advancedOnly" type="button" hidden>Low</button>
<button id="depthClear" class="tool advancedOnly" type="button" hidden>Clear</button>
<span id="advancedHint" class="hint" hidden>Light, Particle, and Depth tools are here. Particle uses the selected palette color; Shift/right-click clears.</span>
</div>
<input id="paintColor" type="color" value="#6bd06b" hidden />
<div id="editHint" class="hint">Palette color is used for pixels, lights, particles, and depth marks. Shortcuts: B/E/F/I/L/R/S, Ctrl/Cmd+Z, arrows move a selection.</div>
</div>
<div class="card stack summonCard">
<div class="cardTitle">Finish</div>
<div class="actionRow finishActions">
<button id="saveAndPlace" class="primary bigPrimary">Save + Place on Island</button>
<button id="saveAsset" class="secondary">Save to Library</button>
<button id="checkOnIsland" class="secondary">Check on Island</button>
<button id="newAsset" class="secondary">New</button>
<button id="saveAndPlace" class="primary bigPrimary"><span>Publish</span><span id="finishQuotaBadge" class="quotaBadge finishQuotaBadge" aria-live="polite"></span></button>
<button id="saveAsset" class="secondary">Save to Collection</button>
</div>
</div>
<div id="lineageNote" class="lineageNote"></div>
@ -149,10 +163,10 @@
<section id="tab-library" class="tabPanel">
<div class="card stack">
<div class="libraryHead">
<div class="cardTitle">Library</div>
<div class="cardTitle">Collection</div>
<button id="showHiddenAssets" class="tool" type="button">Hidden</button>
</div>
<p class="hint">Your works are permanent here. The island is a temporary exhibition: place your own new or past works; remix only copies lineage and pixels to the canvas.</p>
<p class="hint">Your works are permanent here. The island is a temporary exhibition.</p>
<div id="hiddenAssetPanel" class="hiddenAssetPanel" hidden>
<div class="cardTitle">Hidden</div>
<div id="hiddenAssetList" class="assetList compactAssetList"></div>
@ -162,28 +176,9 @@
</div>
</section>
<section id="tab-data" class="tabPanel">
<section id="tab-settings" class="tabPanel">
<div class="card stack">
<div class="cardTitle">Local save</div>
<p class="hint">Works are durable library assets; island placements are temporary exhibition objects. Export JSON if you want to move or test a local world.</p>
<div class="actionRow wrap">
<button id="exportData">Export full JSON</button>
<button id="exportCompact">Export compact</button>
<button id="exportSnapshot">Export snapshot</button>
<button id="exportAssetBundle">Export asset bundle</button>
<button id="importData">Import JSON</button>
<button id="exportPng" type="button">Export PNG</button>
<button id="importPng" type="button">Import PNG</button>
<input id="pngImportInput" type="file" accept="image/png,image/*" hidden />
<button id="resetAll" class="danger">Reset all</button>
</div>
<div id="syncStats" class="syncStats"></div>
<textarea id="dataBox" rows="10" placeholder="Exported JSON appears here."></textarea>
</div>
<div class="card stack">
<div class="cardTitle">Visual settings</div>
<div class="cardTitle">Settings</div>
<p class="hint">Turn major visual systems on or off. Server decides the public 250 exhibition slots; this local cap only filters your view.</p>
<div class="toggleList">
<label class="checkRow"><input id="settingLights" type="checkbox" checked /> <span>Lights & glow</span></label>
@ -195,18 +190,8 @@
</div>
<div id="rotationStats" class="syncStats"></div>
</div>
<div class="card stack">
<div class="cardTitle">Phase 5 guardrails</div>
<p class="hint">Local pre-server checks for object volume, orphaned placements, terrain compatibility, hidden objects, and report logs.</p>
<div id="guardrailStats" class="syncStats"></div>
<div class="actionRow wrap">
<button id="validateWorld" type="button">Validate world</button>
<button id="exportModerationReport" type="button">Export moderation report</button>
<button id="clearReports" class="danger" type="button">Clear reports</button>
</div>
</div>
</section>
</div>
</section>
@ -221,13 +206,28 @@
<button id="voteDown" type="button"></button>
</div>
<div class="bubbleActions">
<button id="bubbleRemix" type="button">Remix</button>
<button id="bubbleRemix" class="bubbleRemixPrimary" type="button">Remix</button>
<button id="bubbleTeleport" type="button" hidden>Teleport to source</button>
</div>
<button id="bubbleMenu" class="bubbleMenuButton" type="button">Menu</button>
<div id="bubbleMenuActions" class="bubbleMenuActions" hidden>
<button id="bubbleReport" type="button">Report</button>
<button id="bubbleHide" type="button" hidden>Hide</button>
</div>
</div>
<div id="toast" class="toast" hidden></div>
<div id="confirmDialog" class="reportDialog" hidden>
<div class="reportCard" role="dialog" aria-modal="true" aria-labelledby="confirmTitle">
<div id="confirmTitle" class="cardTitle">Delete work</div>
<p id="confirmMessage" class="hint"></p>
<div class="actionRow wrap reportActions">
<button id="confirmOk" class="danger" type="button">Delete</button>
<button id="confirmCancel" type="button">Cancel</button>
</div>
</div>
</div>
<div id="reportDialog" class="reportDialog" hidden>
<div class="reportCard" role="dialog" aria-modal="true" aria-labelledby="reportTitle">
<div id="reportTitle" class="cardTitle">Report object</div>
@ -249,7 +249,11 @@
</div>
</div>
<script src="./js/editor-actions.js"></script>
<script src="./js/core-utils.js"></script>
<script src="./js/module-loader.js"></script>
<script src="./js/state-index.js"></script>
<script src="./js/rotation-policy.js"></script>
<script src="./js/lighting.js"></script>
<script src="./js/phase2-sync.js"></script>
<script src="./app.js"></script>
</body>

View file

@ -1,203 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pixel Island Summoner - Local Prototype</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div class="app">
<canvas id="worldCanvas" aria-label="Island map"></canvas>
<button id="openEditor" class="edgeAdd" title="Open Pixel Studio">+</button>
<header class="hud topHud">
<div>
<div class="logo">Pixel Island</div>
<div class="subline">Local prototype / Draw pixels, summon them to islands.</div>
</div>
<label class="authorCard">
<span>Your Name</span>
<input id="authorName" type="text" maxlength="24" value="Local Artist" />
</label>
<div class="clockCard" aria-label="world clock">
<div id="phaseLabel" class="phaseLabel">Day</div>
<div class="phaseBar"><span id="phaseBar"></span></div>
<div class="clockHint">10 min = 1 day</div>
</div>
</header>
<aside class="hud toolHud">
<button id="modeInspect" class="iconButton active" title="Pan / Inspect">Pan</button>
<button id="modePlace" class="iconButton" title="Summon selected asset">Place</button>
<button id="modeErase" class="iconButton danger" title="Remove asset">Erase</button>
<div class="divider"></div>
<button id="zoomOut" class="iconButton" title="Zoom out"></button>
<button id="zoomIn" class="iconButton" title="Zoom in">+</button>
<button id="resetView" class="iconButton" title="Reset view">Reset</button>
</aside>
<section id="studioDrawer" class="studioDrawer" aria-label="Pixel Studio">
<div class="drawerHead">
<div>
<h1>Pixel Studio</h1>
<p>Make static scenery or moving island life.</p>
</div>
<button id="closeEditor" class="ghostButton" title="Close">×</button>
</div>
<nav class="tabs" aria-label="Studio tabs">
<button class="tab active" data-tab="draw">Draw</button>
<button class="tab" data-tab="library">Library</button>
<button class="tab" data-tab="data">Data</button>
</nav>
<div class="drawerBody">
<section id="tab-draw" class="tabPanel active">
<div class="card editorCard heroEditor">
<div class="quickSetupBar twoCols">
<label class="field">Canvas size
<select id="assetSize">
<option value="8" selected>8×8</option>
<option value="16">16×16</option>
<option value="32">32×32</option>
<option value="64">64×64</option>
</select>
</label>
<label class="field">Role
<select id="assetCategory">
<option value="human">Human</option>
<option value="animal">Animal</option>
<option value="nature" selected>Nature</option>
<option value="building">Building</option>
<option value="other">Other</option>
</select>
</label>
</div>
<label class="field compactNameField">Name
<input id="assetName" type="text" maxlength="32" placeholder="Tiny bakery, round tree, island pup..." />
</label>
<div class="editorTop compactEditorTop">
<div id="sideSwitcher" class="sideSwitcher" hidden aria-label="Sprite direction">
<button id="editLeft" title="Left-facing sprite">◀ Left</button>
<button id="editRight" class="active" title="Right-facing sprite is the main/front direction">▶ Right</button>
</div>
</div>
<div class="paintLayout">
<canvas id="paintCanvas" width="512" height="512" aria-label="Pixel editor"></canvas>
<div class="palettePanel">
<div id="paletteGrid" class="paletteGrid" aria-label="Color palette"></div>
</div>
</div>
<div class="toolRow editorToolRow">
<button id="toolBrush" class="tool active" title="B">Draw</button>
<button id="toolErase" class="tool" title="E">Erase</button>
<button id="toolFill" class="tool" title="F">Fill</button>
<button id="toolPick" class="tool" title="I">Pick</button>
<button id="toolLine" class="tool" title="L">Line</button>
<button id="toolRect" class="tool" title="R">Rect</button>
<button id="toolSelect" class="tool" title="S">Select</button>
<button id="toolDoor" class="tool buildingOnly">Door</button>
<button id="clearPaint" class="tool danger">Clear</button>
</div>
<div class="toolRow editorHistoryRow">
<button id="undoPaint" class="tool" type="button" disabled>Undo</button>
<button id="redoPaint" class="tool" type="button" disabled>Redo</button>
<button id="outlinePaint" class="tool" type="button">Outline</button>
<button id="flipHorizontal" class="tool" type="button">Flip H</button>
<button id="flipVertical" class="tool" type="button">Flip V</button>
</div>
<div class="toolRow editorMoveRow">
<button id="nudgeLeft" class="tool" type="button"></button>
<button id="nudgeUp" class="tool" type="button"></button>
<button id="nudgeDown" class="tool" type="button"></button>
<button id="nudgeRight" class="tool" type="button"></button>
<button id="clearSelection" class="tool" type="button" disabled>Clear Sel</button>
</div>
<div class="toolRow editorFileRow">
<button id="exportPng" class="tool" type="button">Export PNG</button>
<button id="importPng" class="tool" type="button">Import PNG</button>
<input id="pngImportInput" type="file" accept="image/png,image/*" hidden />
</div>
<div class="advancedRow">
<button id="toggleAdvanced" class="tool" type="button">Advanced Draw</button>
<button id="toolLight" class="tool advancedOnly" hidden>Light</button>
<button id="toolDepth" class="tool advancedOnly" hidden>Depth</button>
<span id="advancedHint" class="hint" hidden>Light/Depth tools are here. Depth paints visible blue height marks; Shift/right-click erases.</span>
</div>
<input id="paintColor" type="color" value="#6bd06b" hidden />
<div id="editHint" class="hint">Palette color is used for pixels, lights, and depth marks. Shortcuts: B/E/F/I/L/R/S, Ctrl/Cmd+Z, arrows move a selection.</div>
</div>
<div class="card stack summonCard">
<div class="cardTitle">Finish</div>
<div class="actionRow finishActions">
<button id="saveAndPlace" class="primary bigPrimary">Save + Summon on Map</button>
<button id="saveAsset" class="secondary">Save only</button>
<button id="newAsset" class="secondary">New</button>
</div>
</div>
<div id="lineageNote" class="lineageNote"></div>
</section>
<section id="tab-library" class="tabPanel">
<div class="card stack">
<div class="libraryHead">
<div class="cardTitle">Library</div>
<button id="showHiddenAssets" class="tool" type="button">Hidden</button>
</div>
<p class="hint">Click an asset to select it and jump to it on the map. Copy Edit creates a new derivative.</p>
<div id="hiddenAssetPanel" class="hiddenAssetPanel" hidden>
<div class="cardTitle">Hidden assets</div>
<div id="hiddenAssetList" class="assetList compactAssetList"></div>
</div>
<div id="assetList" class="assetList"></div>
</div>
</section>
<section id="tab-data" class="tabPanel">
<div class="card stack">
<div class="cardTitle">Local save</div>
<p class="hint">Saved to this browser only. Export JSON if you want to move or share a local world.</p>
<div class="actionRow wrap">
<button id="exportData">Export full JSON</button>
<button id="exportCompact">Export compact</button>
<button id="exportSnapshot">Export snapshot</button>
<button id="exportAssetBundle">Export asset bundle</button>
<button id="importData">Import JSON</button>
<button id="resetAll" class="danger">Reset all</button>
</div>
<div id="syncStats" class="syncStats"></div>
<textarea id="dataBox" rows="10" placeholder="Exported JSON appears here."></textarea>
</div>
</section>
</div>
</section>
<div id="selectionBubble" class="selectionBubble" hidden>
<div id="bubbleName" class="bubbleName">Name</div>
<div id="bubbleAuthor" class="bubbleAuthor">by Author</div>
<div id="bubbleRemixFrom" class="bubbleRemixFrom" hidden></div>
<div id="bubbleRemixCount" class="bubbleRemixCount">Remixed: 0</div>
<div class="bubbleVotes">
<button id="voteUp" type="button"></button>
<span id="voteScore">0</span>
<button id="voteDown" type="button"></button>
</div>
<div class="bubbleActions">
<button id="bubbleRemix" type="button">Remix</button>
<button id="bubbleHide" type="button" hidden>Hide</button>
</div>
</div>
<div id="toast" class="toast" hidden></div>
</div>
<script src="./js/editor-actions.js"></script>
<script src="./js/phase2-sync.js"></script>
<script src="./app.js"></script>
</body>
</html>

33
js/core-utils.js Normal file
View file

@ -0,0 +1,33 @@
(function () {
'use strict';
const root = window.PixelIslandModules ||= {};
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function mod(n, m) {
return ((n % m) + m) % m;
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
function fnv1a(value) {
let hash = 0x811c9dc5;
const text = String(value);
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16).padStart(8, '0');
}
function uid() {
return Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-5);
}
root.CoreUtils = { clamp, mod, lerp, fnv1a, uid };
})();

View file

@ -7,9 +7,11 @@
return Array.isArray(pixels) ? [...pixels] : [];
}
function floodFill(sourcePixels, size, x, y, colorCode) {
function floodFill(sourcePixels, width, x, y, colorCode, height = width) {
const w = Math.max(1, Math.round(Number(width) || 1));
const h = Math.max(1, Math.round(Number(height) || w));
const pixels = clonePixels(sourcePixels);
const target = pixels[y * size + x] || null;
const target = pixels[y * w + x] || null;
const replacement = colorCode || null;
if (target === replacement) return { pixels, changed: false, count: 0, cells: [] };
@ -17,8 +19,8 @@
const cells = [];
while (stack.length) {
const [cx, cy] = stack.pop();
if (cx < 0 || cy < 0 || cx >= size || cy >= size) continue;
const index = cy * size + cx;
if (cx < 0 || cy < 0 || cx >= w || cy >= h) continue;
const index = cy * w + cx;
if ((pixels[index] || null) !== target) continue;
pixels[index] = replacement;
cells.push({ x: cx, y: cy });

View file

@ -2,67 +2,115 @@
'use strict';
const root = window.PixelIslandModules ||= {};
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const lerp = (a, b, t) => a + (b - a) * t;
const mod = (n, m) => ((n % m) + m) % m;
const utils = root.CoreUtils || {};
const clamp = utils.clamp || ((value, min, max) => Math.max(min, Math.min(max, value)));
const lerp = utils.lerp || ((a, b, t) => a + (b - a) * t);
const mod = utils.mod || ((n, m) => ((n % m) + m) % m);
function getCelestialLightForMinute(minute) {
const isNight = minute >= 6;
const local = isNight ? (minute - 6) / 4 : minute / 6;
const t = clamp(local, 0, 1);
const eased = t * t * (3 - 2 * t);
const elevation = Math.max(.08, Math.sin(t * Math.PI));
const sourceX = isNight ? lerp(-1.05, 1.05, eased) : lerp(1.18, -1.18, eased);
const sourceY = isNight ? -.46 - elevation * .24 : -.58 - elevation * .34;
return {
x: sourceX,
y: sourceY,
elevation,
isNight,
shadeAlpha: isNight ? .18 : lerp(.20, .08, elevation)
};
function smooth(t) {
const n = clamp(t, 0, 1);
return n * n * (3 - 2 * n);
}
function getShadowForMinute(minute) {
const light = getCelestialLightForMinute(minute);
const length = lerp(light.isNight ? 1.35 : 1.9, light.isNight ? .85 : .52, light.elevation);
return {
x: -light.x * 6.5 * length,
y: Math.min(-1.8, light.y * 4.2 * length),
length,
alpha: light.isNight ? .085 : lerp(.27, .12, light.elevation)
};
const m = clamp(Number(minute) || 0, 0, 10);
let dirX = -0.9;
let length = 1.2;
let alpha = 0;
let scaleY = 0.26;
if (m < 0.65) {
const t = smooth(m / 0.65);
dirX = lerp(0.42, 0.18, t);
length = lerp(1.05, 0.92, t);
alpha = lerp(0.12, 0.0, t);
scaleY = lerp(0.22, 0.18, t);
} else if (m < 1.35) {
const t = smooth((m - 0.65) / 0.70);
dirX = lerp(-1.05, -0.82, t);
length = lerp(1.38, 1.12, t);
alpha = lerp(0.0, 0.27, t);
scaleY = lerp(0.22, 0.27, t);
} else if (m < 5.65) {
const t = smooth((m - 1.35) / 4.30);
const elevation = Math.sin(t * Math.PI);
dirX = lerp(-0.82, 0.98, t);
length = lerp(1.12, 1.34, t) - elevation * 0.48;
alpha = lerp(0.27, 0.20, elevation);
scaleY = 0.24 + 0.06 * (1 - elevation);
} else if (m < 6.40) {
const t = smooth((m - 5.65) / 0.75);
dirX = lerp(0.98, 1.08, t);
length = lerp(1.34, 1.56, t);
alpha = lerp(0.20, 0.0, t);
scaleY = lerp(0.28, 0.20, t);
} else if (m < 7.25) {
const t = smooth((m - 6.40) / 0.85);
dirX = lerp(0.20, -0.36, t);
length = lerp(0.92, 1.08, t);
alpha = lerp(0.0, 0.12, t);
scaleY = lerp(0.18, 0.23, t);
} else if (m < 9.20) {
const t = smooth((m - 7.25) / 1.95);
dirX = lerp(-0.36, -0.58, t);
length = lerp(1.08, 1.18, t);
alpha = 0.12;
scaleY = 0.23;
} else {
const t = smooth((m - 9.20) / 0.80);
dirX = lerp(-0.58, 0.42, t);
length = lerp(1.18, 1.05, t);
alpha = lerp(0.12, 0.0, t);
scaleY = lerp(0.23, 0.18, t);
}
return { dirX, length, skewX: dirX * (0.72 + 0.32 * length), scaleY, alpha };
}
function getPhase(dayMs, now = Date.now()) {
const t = mod(now, dayMs);
function getPhase(options) {
const { dayMs, now, dayNightEnabled, mixHex } = options;
if (dayNightEnabled === false) {
return { key: 'day', label: 'Day', progress: 0.25, sky: '#86d5ff', darkness: 0, darkOverlay: 'rgba(12, 19, 45, 0)', tint: 'rgba(255,255,255,0)', tintAlpha: 0, shadow: getShadowForMinute(3) };
}
const t = mod(now || Date.now(), dayMs);
const minute = t / 60000;
const stops = [
{ at: 0, key: 'morning', label: 'Morning', darkness: 0.18, tint: [255, 208, 144, 0.12] },
{ at: 1, key: 'day', label: 'Day', darkness: 0.00, tint: [255, 255, 255, 0.00] },
{ at: 5, key: 'day', label: 'Day', darkness: 0.00, tint: [255, 255, 255, 0.00] },
{ at: 6, key: 'evening', label: 'Evening', darkness: 0.18, tint: [255, 146, 114, 0.14] },
{ at: 10, key: 'night', label: 'Night', darkness: 0.48, tint: [36, 47, 96, 0.18] }
{ at: 0.00, key: 'preDawn', label: 'Night', sky: '#17254e', darkness: 0.52, tint: [22, 28, 66, 0.10], overlay: [12, 19, 45] },
{ at: 0.65, key: 'dawn', label: 'Morning', sky: '#5969aa', darkness: 0.30, tint: [120, 100, 150, 0.10], overlay: [40, 43, 78] },
{ at: 1.35, key: 'morning', label: 'Morning', sky: '#f6b16b', darkness: 0.12, tint: [255, 195, 126, 0.13], overlay: [114, 64, 36] },
{ at: 2.15, key: 'day', label: 'Day', sky: '#8ad8ff', darkness: 0.00, tint: [255, 255, 255, 0.00], overlay: [12, 19, 45] },
{ at: 4.75, key: 'day', label: 'Day', sky: '#8ad8ff', darkness: 0.00, tint: [255, 255, 255, 0.00], overlay: [12, 19, 45] },
{ at: 5.65, key: 'evening', label: 'Evening', sky: '#ffa153', darkness: 0.07, tint: [255, 166, 88, 0.10], overlay: [130, 48, 18] },
{ at: 6.40, key: 'evening', label: 'Evening', sky: '#d55a2d', darkness: 0.20, tint: [255, 118, 62, 0.17], overlay: [118, 42, 18] },
{ at: 7.25, key: 'night', label: 'Night', sky: '#1b2a58', darkness: 0.46, tint: [32, 40, 82, 0.07], overlay: [12, 19, 45] },
{ at: 9.20, key: 'night', label: 'Night', sky: '#132247', darkness: 0.58, tint: [16, 24, 58, 0.10], overlay: [12, 19, 45] },
{ at: 10.00, key: 'preDawn', label: 'Night', sky: '#17254e', darkness: 0.52, tint: [22, 28, 66, 0.10], overlay: [12, 19, 45] }
];
let a = stops[0], b = stops[1];
for (let i = 0; i < stops.length - 1; i++) {
if (minute >= stops[i].at && minute < stops[i + 1].at) { a = stops[i]; b = stops[i + 1]; break; }
if (minute >= 6) { a = stops[3]; b = stops[4]; }
if (minute >= stops[i].at && minute < stops[i + 1].at) {
a = stops[i];
b = stops[i + 1];
break;
}
}
const localT = clamp((minute - a.at) / Math.max(0.0001, b.at - a.at), 0, 1);
const eased = localT * localT * (3 - 2 * localT);
const tint = a.tint.map((v, i) => lerp(v, b.tint[i], eased));
const label = minute < 1 ? 'Morning' : minute < 5 ? 'Day' : minute < 6 ? 'Evening' : 'Night';
const eased = smooth((minute - a.at) / Math.max(0.0001, b.at - a.at));
const tint = a.tint.map((value, i) => lerp(value, b.tint[i], eased));
const overlay = a.overlay.map((value, i) => lerp(value, b.overlay[i], eased));
const darkness = lerp(a.darkness, b.darkness, eased);
const dominantKey = darkness >= 0.34 ? 'night' : (a.key === 'evening' || b.key === 'evening' ? 'evening' : (a.key === 'morning' || b.key === 'morning' || a.key === 'dawn' || b.key === 'dawn' ? 'morning' : 'day'));
return {
key: label.toLowerCase(),
label,
key: dominantKey,
label: dominantKey === 'night' ? 'Night' : dominantKey === 'evening' ? 'Evening' : dominantKey === 'morning' ? 'Morning' : 'Day',
progress: t / dayMs,
darkness: lerp(a.darkness, b.darkness, eased),
sky: mixHex(a.sky, b.sky, eased),
darkness,
darkOverlay: `rgba(${Math.round(overlay[0])}, ${Math.round(overlay[1])}, ${Math.round(overlay[2])}, ${darkness.toFixed(3)})`,
tint: `rgba(${Math.round(tint[0])}, ${Math.round(tint[1])}, ${Math.round(tint[2])}, ${tint[3].toFixed(3)})`,
light: getCelestialLightForMinute(minute),
tintAlpha: tint[3],
shadow: getShadowForMinute(minute)
};
}
root.Lighting = { getPhase, getShadowForMinute, getCelestialLightForMinute };
root.Lighting = { getPhase, getShadowForMinute };
})();

22
js/module-loader.js Normal file
View file

@ -0,0 +1,22 @@
(function () {
'use strict';
const root = window.PixelIslandModules ||= {};
const pending = new Map();
function loadScript(src) {
if (pending.has(src)) return pending.get(src);
const promise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.defer = true;
script.onload = () => resolve(script);
script.onerror = () => reject(new Error(`Could not load ${src}`));
document.head.appendChild(script);
});
pending.set(src, promise);
return promise;
}
root.ModuleLoader = { loadScript };
})();

View file

@ -2,21 +2,134 @@
'use strict';
const root = window.PixelIslandModules ||= {};
const FORMAT = 'pixel-island-phase2-compact-v1';
const FORMAT = 'pixel-island-phase2-compact-v2';
const FORMAT_V1 = 'pixel-island-phase2-compact-v1';
const SNAPSHOT_FORMAT = 'pixel-island-phase2-snapshot-v1';
const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v1';
const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v2';
const ASSET_BUNDLE_FORMAT_V1 = 'pixel-island-phase2-asset-bundle-v1';
const EVENT_LOG_LIMIT = 300;
const DB_NAME = 'pixel-island-phase2-cache';
const DB_VERSION = 1;
const ASSET_STORE = 'assets';
const SNAPSHOT_STORE = 'snapshots';
const COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const PACK6_CHARS = `.${COLOR_CODES}`;
function isCompactState(value) {
return Boolean(value && value.format === FORMAT && Array.isArray(value.assets));
return Boolean(value && (value.format === FORMAT || value.format === FORMAT_V1) && Array.isArray(value.assets));
}
function isAssetBundle(value) {
return Boolean(value && value.format === ASSET_BUNDLE_FORMAT && Array.isArray(value.assets));
return Boolean(value && (value.format === ASSET_BUNDLE_FORMAT || value.format === ASSET_BUNDLE_FORMAT_V1) && Array.isArray(value.assets));
}
function bytesToBase64(bytes) {
if (!bytes || !bytes.length) return '';
if (typeof btoa === 'function') {
let binary = '';
const chunk = 8192;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.slice(i, i + chunk));
}
return btoa(binary);
}
if (typeof Buffer !== 'undefined') return Buffer.from(bytes).toString('base64');
return '';
}
function base64ToBytes(value) {
const text = String(value || '');
if (!text) return [];
if (typeof atob === 'function') {
const binary = atob(text);
const out = new Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) & 255;
return out;
}
if (typeof Buffer !== 'undefined') return Array.from(Buffer.from(text, 'base64'));
return [];
}
function pack6Index(ch) {
const index = PACK6_CHARS.indexOf(ch || '.');
return index >= 0 && index < 64 ? index : 0;
}
function unpack6Index(value) {
return PACK6_CHARS[value] || '.';
}
function canPack6(text) {
for (const ch of String(text || '')) if (PACK6_CHARS.indexOf(ch) < 0) return false;
return true;
}
function pack6Text(input) {
const text = String(input || '');
if (!text) return '';
let buffer = 0;
let bitCount = 0;
const bytes = [];
for (const ch of text) {
buffer = (buffer << 6) | pack6Index(ch);
bitCount += 6;
while (bitCount >= 8) {
bitCount -= 8;
bytes.push((buffer >> bitCount) & 255);
buffer &= (1 << bitCount) - 1;
}
}
if (bitCount > 0) bytes.push((buffer << (8 - bitCount)) & 255);
return bytesToBase64(bytes);
}
function unpack6Text(input, length) {
const target = Math.max(0, Number(length) || 0);
if (!target) return '';
const bytes = base64ToBytes(input);
let buffer = 0;
let bitCount = 0;
let out = '';
for (const byte of bytes) {
buffer = (buffer << 8) | (byte & 255);
bitCount += 8;
while (bitCount >= 6 && out.length < target) {
bitCount -= 6;
out += unpack6Index((buffer >> bitCount) & 63);
buffer &= (1 << bitCount) - 1;
}
if (out.length >= target) break;
}
return (out + '.'.repeat(target)).slice(0, target);
}
function packBitMask(mask) {
const bits = Array.from(mask || [], Boolean);
if (!bits.length) return '';
const bytes = [];
let current = 0;
for (let i = 0; i < bits.length; i++) {
current = (current << 1) | (bits[i] ? 1 : 0);
if (i % 8 === 7) {
bytes.push(current & 255);
current = 0;
}
}
const rest = bits.length % 8;
if (rest) bytes.push((current << (8 - rest)) & 255);
return bytesToBase64(bytes);
}
function unpackBitMask(input, length) {
const target = Math.max(0, Number(length) || 0);
const bytes = base64ToBytes(input);
const out = [];
for (const byte of bytes) {
for (let bit = 7; bit >= 0 && out.length < target; bit--) out.push(Boolean((byte >> bit) & 1));
if (out.length >= target) break;
}
while (out.length < target) out.push(false);
return out;
}
function rleEncode(input) {
@ -55,21 +168,45 @@
return out;
}
function normalizeEncodedPlane(value, size, emptyChar = '.') {
const total = Math.max(1, Number(size) || 1) ** 2;
function assetWidth(asset) {
const size = Math.max(1, Number(asset?.size) || 16);
return Math.max(1, Number(asset?.width ?? asset?.w ?? size) || size);
}
function assetHeight(asset) {
const size = Math.max(1, Number(asset?.size) || 16);
return Math.max(1, Number(asset?.height ?? asset?.ht ?? size) || size);
}
function normalizeEncodedPlane(value, width, emptyChar = '.', height = width) {
const w = Math.max(1, Number(width) || 1);
const h = Math.max(1, Number(height) || w);
const total = w * h;
const source = typeof value === 'string' ? value : Array.isArray(value) ? value.map((v) => v || emptyChar).join('') : '';
return (source + emptyChar.repeat(total)).slice(0, total);
}
function cropPlane(encoded, size, emptyChar = '.') {
const text = normalizeEncodedPlane(encoded, size, emptyChar);
let minX = size;
let minY = size;
function chooseSmallest(candidates) {
let best = candidates[0];
let bestSize = JSON.stringify(best).length;
for (const candidate of candidates.slice(1)) {
const size = JSON.stringify(candidate).length;
if (size < bestSize) { best = candidate; bestSize = size; }
}
return best;
}
function cropPlane(encoded, width, emptyChar = '.', options = {}, height = width) {
const w0 = Math.max(1, Number(width) || 1);
const h0 = Math.max(1, Number(height) || w0);
const text = normalizeEncodedPlane(encoded, w0, emptyChar, h0);
let minX = w0;
let minY = h0;
let maxX = -1;
let maxY = -1;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (text[y * size + x] !== emptyChar) {
for (let y = 0; y < h0; y++) {
for (let x = 0; x < w0; x++) {
if (text[y * w0 + x] !== emptyChar) {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
@ -81,49 +218,76 @@
const w = maxX - minX + 1;
const h = maxY - minY + 1;
let cropped = '';
for (let y = minY; y <= maxY; y++) {
cropped += text.slice(y * size + minX, y * size + minX + w);
}
const rle = rleEncode(cropped);
return rle.length < cropped.length ? { b: [minX, minY, w, h], e: 'rle', v: rle } : { b: [minX, minY, w, h], e: 'raw', v: cropped };
for (let y = minY; y <= maxY; y++) cropped += text.slice(y * w0 + minX, y * w0 + minX + w);
const candidates = [
{ b: [minX, minY, w, h], e: 'raw', v: cropped },
{ b: [minX, minY, w, h], e: 'rle', v: rleEncode(cropped) }
];
if (options.bitPack && canPack6(cropped)) candidates.push({ b: [minX, minY, w, h], e: 'bp6', v: pack6Text(cropped), n: cropped.length });
return chooseSmallest(candidates);
}
function expandPlane(packed, size, emptyChar = '.') {
const total = Math.max(1, Number(size) || 1) ** 2;
function expandPlane(packed, width, emptyChar = '.', baseEncoded = null, height = width) {
const w0 = Math.max(1, Number(width) || 1);
const h0 = Math.max(1, Number(height) || w0);
const total = w0 * h0;
const out = Array(total).fill(emptyChar);
if (baseEncoded) {
const base = normalizeEncodedPlane(baseEncoded, w0, emptyChar, h0);
for (let i = 0; i < Math.min(out.length, base.length); i++) out[i] = base[i] || emptyChar;
}
if (!packed || !packed.b) return out.join('');
const [x0, y0, w, h] = packed.b.map((v) => Math.max(0, Number(v) || 0));
const value = packed.e === 'rle' ? rleDecode(packed.v) : String(packed.v || '');
let value = '';
if (packed.e === 'rle') value = rleDecode(packed.v);
else if (packed.e === 'bp6') value = unpack6Text(packed.v, packed.n || (w * h));
else value = String(packed.v || '');
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const src = y * w + x;
const dx = x0 + x;
const dy = y0 + y;
if (dx >= 0 && dy >= 0 && dx < size && dy < size && src < value.length) {
out[dy * size + dx] = value[src] || emptyChar;
}
if (dx >= 0 && dy >= 0 && dx < w0 && dy < h0 && src < value.length) out[dy * w0 + dx] = value[src] || emptyChar;
}
}
return out.join('');
}
function packAsset(asset) {
const size = Math.max(1, Number(asset.size) || 16);
function planeForAsset(asset) {
const w = assetWidth(asset);
const h = assetHeight(asset);
return normalizeEncodedPlane(asset?.faces?.right || asset?.pixels || '', w, '.', h);
}
function selectPixelPlanePack(asset, assetMap) {
const w = assetWidth(asset);
const h = assetHeight(asset);
const right = planeForAsset(asset);
return cropPlane(right, w, '.', { bitPack: true }, h);
}
function packAsset(asset, assetMap = null) {
const width = assetWidth(asset);
const height = assetHeight(asset);
const size = Math.max(width, height, Math.max(1, Number(asset.size) || 16));
const category = asset.category === 'dynamic' ? 'dynamic' : 'static';
const right = normalizeEncodedPlane(asset.faces?.right || asset.pixels || '', size, '.');
const depth = asset.meta?.depthPixels ? normalizeEncodedPlane(asset.meta.depthPixels, size, '.') : '';
const depth = asset.meta?.depthPixels ? normalizeEncodedPlane(asset.meta.depthPixels, width, '.', height) : '';
const lights = Array.isArray(asset.meta?.lightPixels)
? asset.meta.lightPixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || asset.meta?.lightColor || '']).filter((p) => p[2])
: [];
const particles = Array.isArray(asset.meta?.particlePixels)
? asset.meta.particlePixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || '']).filter((p) => p[2])
? asset.meta.particlePixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || '', p.dir || 'up']).filter((p) => p[2])
: [];
const meta = {};
if (depth && /[1\-]/.test(depth)) meta.d = cropPlane(depth, size, '.');
if (depth && /[1\-]/.test(depth)) meta.d = cropPlane(depth, width, '.', {}, height);
if (lights.length) meta.l = lights;
if (particles.length) meta.pt = particles;
if (asset.meta?.lightColor) meta.lc = asset.meta.lightColor;
if (asset.meta?.door) meta.dr = [Number(asset.meta.door.x) || 0, Number(asset.meta.door.y) || 0];
if (asset.meta?.particleConfig?.enabled && !particles.length) {
const pc = asset.meta.particleConfig;
meta.pc = [pc.c || '', pc.dir || 'up', null];
}
return {
id: asset.id,
@ -132,36 +296,47 @@
c: category,
t: asset.subtype || (category === 'dynamic' ? 'human' : 'other'),
s: size,
p: cropPlane(right, size, '.'),
w: width !== size ? width : undefined,
ht: height !== size ? height : undefined,
p: selectPixelPlanePack(asset, assetMap),
f: category === 'dynamic' ? { l: 'mirror' } : null,
pa: asset.parentAssetId || null,
oa: asset.originalAssetId || null,
ca: asset.createdAt || Date.now(),
ua: asset.updatedAt || asset.createdAt || Date.now(),
au: asset.author || 'Local Artist',
ow: asset.ownerAccountId || null,
v: Number(asset.version) || 1,
m: Object.keys(meta).length ? meta : null
};
}
function unpackAsset(packed) {
function unpackAsset(packed, assetById = null) {
if (!packed || !packed.id) return null;
const size = Math.max(1, Number(packed.s || packed.size) || 16);
const width = Math.max(1, Number(packed.w || packed.width || size) || size);
const height = Math.max(1, Number(packed.ht || packed.height || size) || size);
const category = packed.c === 'dynamic' || packed.category === 'dynamic' ? 'dynamic' : 'static';
const pixels = expandPlane(packed.p, size, '.');
const pixels = expandPlane(packed.p, width, '.', null, height);
if (pixels == null) return null;
const metaPacked = packed.m || {};
const lightPixels = Array.isArray(metaPacked.l)
? metaPacked.l.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || metaPacked.lc || 'a' }))
: [];
const particlePixels = Array.isArray(metaPacked.pt)
? metaPacked.pt.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || 'a' }))
? metaPacked.pt.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || 'a', dir: p[3] || 'up' }))
: [];
const particleConfig = Array.isArray(metaPacked.pc)
? { enabled: true, c: metaPacked.pc[0] || 'a', dir: metaPacked.pc[1] || 'up', range: Array.isArray(metaPacked.pc[2]) ? { x: metaPacked.pc[2][0] || 0, y: metaPacked.pc[2][1] || 0, w: metaPacked.pc[2][2] || 0, h: metaPacked.pc[2][3] || 0 } : null }
: null;
const meta = {
hasLight: lightPixels.length > 0,
lightPixels,
lightColor: lightPixels.length > 0 ? (metaPacked.lc || lightPixels[0]?.c || null) : null,
hasParticles: particlePixels.length > 0,
hasParticles: Boolean(particleConfig) || particlePixels.length > 0,
particlePixels,
depthPixels: metaPacked.d ? expandPlane(metaPacked.d, size, '.') : null,
particleConfig,
depthPixels: metaPacked.d ? expandPlane(metaPacked.d, width, '.', null, height) : null,
door: Array.isArray(metaPacked.dr) ? { x: Number(metaPacked.dr[0]) || 0, y: Number(metaPacked.dr[1]) || 0 } : null
};
return {
@ -170,6 +345,8 @@
category,
subtype: packed.t || (category === 'dynamic' ? 'human' : 'other'),
size,
width,
height,
pixels,
faces: category === 'dynamic' ? { right: pixels, left: 'mirror' } : null,
parentAssetId: packed.pa || null,
@ -177,43 +354,57 @@
createdAt: packed.ca || Date.now(),
updatedAt: packed.ua || packed.ca || Date.now(),
author: packed.au || 'Local Artist',
ownerAccountId: packed.ow || packed.ownerAccountId || '',
version: Number(packed.v || packed.version) || 1,
meta,
contentHash: packed.h || null
};
}
function unpackAssets(rows) {
return (Array.isArray(rows) ? rows : []).map((row) => unpackAsset(row)).filter(Boolean);
}
function packPlacement(item) {
const meta = {};
if (item.publishedAt) meta.pu = item.publishedAt;
if (item.status && item.status !== 'active') meta.st = item.status;
if (item.ownerAccountId) meta.ow = item.ownerAccountId;
return [item.id, item.assetId, Number(item.x) || 0, Number(item.y) || 0, item.placedAt || Date.now(), Number(item.version) || 1, Object.keys(meta).length ? meta : null];
}
function unpackPlacement(row) {
if (!Array.isArray(row)) return row;
const meta = row[6] || {};
return { id: row[0], assetId: row[1], x: row[2], y: row[3], placedAt: row[4], version: row[5] || 1, publishedAt: meta.pu || row[4], status: meta.st || 'active' };
return { id: row[0], assetId: row[1], x: row[2], y: row[3], placedAt: row[4], version: row[5] || 1, publishedAt: meta.pu || row[4], status: meta.st || 'active', ownerAccountId: meta.ow || '' };
}
function packDynamic(item) {
const meta = {};
if (item.publishedAt) meta.pu = item.publishedAt;
if (item.status && item.status !== 'active') meta.st = item.status;
if (item.ownerAccountId) meta.ow = item.ownerAccountId;
return [item.id, item.assetId, Number(item.homeX) || 0, Number(item.homeY) || 0, item.createdAt || Date.now(), Number(item.version) || 1, Object.keys(meta).length ? meta : null];
}
function unpackDynamic(row) {
if (!Array.isArray(row)) return row;
const meta = row[6] || {};
return { id: row[0], assetId: row[1], homeX: row[2], homeY: row[3], createdAt: row[4], version: row[5] || 1, publishedAt: meta.pu || row[4], status: meta.st || 'active' };
return { id: row[0], assetId: row[1], homeX: row[2], homeY: row[3], createdAt: row[4], version: row[5] || 1, publishedAt: meta.pu || row[4], status: meta.st || 'active', ownerAccountId: meta.ow || '' };
}
function assetMapFor(assets) {
return new Map((assets || []).map((asset) => [asset.id, asset]));
}
function compactState(state) {
const assets = Array.isArray(state.assets) ? state.assets : [];
const map = assetMapFor(assets);
return {
schema: 3,
schema: 4,
format: FORMAT,
authorName: state.authorName || 'Local Artist',
assets: Array.isArray(state.assets) ? state.assets.map(packAsset) : [],
assets: assets.map((asset) => packAsset(asset, map)),
placed: Array.isArray(state.placed) ? state.placed.map(packPlacement) : [],
dynamicSummons: Array.isArray(state.dynamicSummons) ? state.dynamicSummons.map(packDynamic) : [],
objectVotes: state.objectVotes || {},
@ -226,16 +417,20 @@
account: state.account || null,
publishLog: Array.isArray(state.publishLog) ? state.publishLog.slice(-300) : [],
eventLog: Array.isArray(state.eventLog) ? state.eventLog.slice(-EVENT_LOG_LIMIT) : [],
sync: state.sync || { lastEventId: null }
sync: state.sync || { lastEventId: null },
worldMode: state.worldMode === 'shared' ? 'shared' : 'local',
serverSync: state.serverSync || { lastServerEventId: null, pendingCommands: [] },
tombstones: state.tombstones || { assets: {}, objects: {} },
deletedSeedAssetNames: Array.isArray(state.deletedSeedAssetNames) ? state.deletedSeedAssetNames : []
};
}
function expandState(input) {
if (!isCompactState(input)) return input;
return {
schema: 3,
schema: input.schema || 4,
authorName: input.authorName || 'Local Artist',
assets: input.assets.map(unpackAsset).filter(Boolean),
assets: unpackAssets(input.assets),
placed: (input.placed || []).map(unpackPlacement),
dynamicSummons: (input.dynamicSummons || []).map(unpackDynamic),
objectVotes: input.objectVotes || {},
@ -248,12 +443,16 @@
account: input.account || null,
publishLog: Array.isArray(input.publishLog) ? input.publishLog.slice(-300) : [],
eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-EVENT_LOG_LIMIT) : [],
sync: input.sync || { lastEventId: null }
sync: input.sync || { lastEventId: null },
worldMode: input.worldMode === 'shared' ? 'shared' : 'local',
serverSync: input.serverSync || { lastServerEventId: null, pendingCommands: [] },
tombstones: input.tombstones || { assets: {}, objects: {} },
deletedSeedAssetNames: Array.isArray(input.deletedSeedAssetNames) ? input.deletedSeedAssetNames : []
};
}
function compactSizeReport(state) {
const full = JSON.stringify({ ...state, schema: 3 });
const full = JSON.stringify({ ...state, schema: 4 });
const compact = JSON.stringify(compactState(state));
return {
fullBytes: full.length,
@ -266,12 +465,12 @@
}
function assetManifest(state) {
return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, s: asset.size, c: asset.category, t: asset.subtype }));
return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, s: asset.size, w: asset.width || null, ht: asset.height || null, c: asset.category, t: asset.subtype, pa: asset.parentAssetId || null, oa: asset.originalAssetId || null }));
}
function makeSnapshot(state, worldId = 'local-main') {
return {
schema: 3,
schema: 4,
format: SNAPSHOT_FORMAT,
worldId,
createdAt: Date.now(),
@ -284,13 +483,14 @@
function makeAssetBundle(state, assetIds) {
const wanted = new Set(assetIds || []);
const assets = (state.assets || []).filter((asset) => !wanted.size || wanted.has(asset.id)).map(packAsset);
return { schema: 3, format: ASSET_BUNDLE_FORMAT, createdAt: Date.now(), assets };
const assets = (state.assets || []).filter((asset) => !wanted.size || wanted.has(asset.id));
const map = assetMapFor(assets);
return { schema: 4, format: ASSET_BUNDLE_FORMAT, createdAt: Date.now(), assets: assets.map((asset) => packAsset(asset, map)) };
}
function unpackAssetBundle(bundle) {
if (!isAssetBundle(bundle)) return [];
return bundle.assets.map(unpackAsset).filter(Boolean);
return unpackAssets(bundle.assets);
}
function findMissingAssetIds(snapshot, knownAssetIds) {
@ -306,6 +506,10 @@
return makeEvent('asset.upsert', { asset: packAsset(asset) });
}
function createAssetDeleteEvent(assetId) {
return makeEvent('asset.delete', { assetId });
}
function createObjectUpsertEvent(kind, object) {
return makeEvent('object.upsert', { kind, object: kind === 'dynamic' ? packDynamic(object) : packPlacement(object) });
}
@ -314,15 +518,34 @@
return makeEvent('object.delete', { kind, objectId });
}
function deleteAssetOnly(state, assetId) {
if (!state || !assetId) return state;
const removedObjectIds = new Set();
for (const item of state.placed || []) if (item.assetId === assetId) removedObjectIds.add(item.id);
for (const item of state.dynamicSummons || []) if (item.assetId === assetId) removedObjectIds.add(item.id);
state.assets = (state.assets || []).filter((asset) => asset.id !== assetId);
state.placed = (state.placed || []).filter((item) => item.assetId !== assetId);
state.dynamicSummons = (state.dynamicSummons || []).filter((item) => item.assetId !== assetId);
delete state.assetVotes?.[assetId];
delete state.hiddenAssets?.[assetId];
for (const id of removedObjectIds) delete state.objectVotes?.[id], delete state.hiddenObjects?.[id];
state.moderationReports = (state.moderationReports || []).filter((report) => !removedObjectIds.has(report.objectId));
return state;
}
function applyEvent(state, event) {
if (!state || !event) return state;
if (event.type === 'asset.upsert' && event.asset) {
const asset = unpackAsset(event.asset);
const byId = assetMapFor(state.assets || []);
const asset = unpackAsset(event.asset, byId);
if (!asset) return state;
const index = (state.assets || []).findIndex((a) => a.id === asset.id);
if (index >= 0) state.assets[index] = asset;
else (state.assets ||= []).unshift(asset);
}
if (event.type === 'asset.delete' && event.assetId) {
deleteAssetOnly(state, event.assetId);
}
if (event.type === 'object.upsert') {
if (event.kind === 'dynamic') {
const object = unpackDynamic(event.object);
@ -381,7 +604,7 @@
request.onerror = () => resolve(null);
})));
db.close();
return rows.filter(Boolean).map(unpackAsset).filter(Boolean);
return unpackAssets(rows.filter(Boolean));
}
async function cacheSnapshot(snapshot) {
@ -398,17 +621,28 @@
root.Phase2Sync = {
FORMAT,
FORMAT_V1,
SNAPSHOT_FORMAT,
ASSET_BUNDLE_FORMAT,
ASSET_BUNDLE_FORMAT_V1,
EVENT_LOG_LIMIT,
isCompactState,
isAssetBundle,
rleEncode,
rleDecode,
pack6Text,
unpack6Text,
packBitMask,
unpackBitMask,
cropPlane,
expandPlane,
packAsset,
unpackAsset,
unpackAssets,
packPlacement,
unpackPlacement,
packDynamic,
unpackDynamic,
compactState,
expandState,
compactSizeReport,
@ -419,8 +653,10 @@
findMissingAssetIds,
makeEvent,
createAssetUpsertEvent,
createAssetDeleteEvent,
createObjectUpsertEvent,
createObjectDeleteEvent,
deleteAssetOnly,
applyEvent,
cacheAssets,
readCachedAssets,

View file

@ -1,402 +0,0 @@
(function () {
'use strict';
const root = window.PixelIslandModules ||= {};
const FORMAT = 'pixel-island-phase2-compact-v1';
const SNAPSHOT_FORMAT = 'pixel-island-phase2-snapshot-v1';
const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v1';
const EVENT_LOG_LIMIT = 300;
const DB_NAME = 'pixel-island-phase2-cache';
const DB_VERSION = 1;
const ASSET_STORE = 'assets';
const SNAPSHOT_STORE = 'snapshots';
function isCompactState(value) {
return Boolean(value && value.format === FORMAT && Array.isArray(value.assets));
}
function isAssetBundle(value) {
return Boolean(value && value.format === ASSET_BUNDLE_FORMAT && Array.isArray(value.assets));
}
function rleEncode(input) {
const text = String(input || '');
if (!text) return '';
let out = '';
let last = text[0];
let count = 1;
for (let i = 1; i < text.length; i++) {
const ch = text[i];
if (ch === last) count++;
else {
out += `${count}:${last}`;
last = ch;
count = 1;
}
}
out += `${count}:${last}`;
return out;
}
function rleDecode(input) {
const text = String(input || '');
if (!text) return '';
let out = '';
let i = 0;
while (i < text.length) {
let digits = '';
while (i < text.length && text[i] >= '0' && text[i] <= '9') digits += text[i++];
if (text[i] !== ':') break;
i++;
const ch = text[i++] || '';
const count = Math.max(0, Number(digits) || 0);
out += ch.repeat(count);
}
return out;
}
function normalizeEncodedPlane(value, size, emptyChar = '.') {
const total = Math.max(1, Number(size) || 1) ** 2;
const source = typeof value === 'string' ? value : Array.isArray(value) ? value.map((v) => v || emptyChar).join('') : '';
return (source + emptyChar.repeat(total)).slice(0, total);
}
function cropPlane(encoded, size, emptyChar = '.') {
const text = normalizeEncodedPlane(encoded, size, emptyChar);
let minX = size;
let minY = size;
let maxX = -1;
let maxY = -1;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (text[y * size + x] !== emptyChar) {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
}
}
if (maxX < 0) return { b: null, e: 'raw', v: '' };
const w = maxX - minX + 1;
const h = maxY - minY + 1;
let cropped = '';
for (let y = minY; y <= maxY; y++) {
cropped += text.slice(y * size + minX, y * size + minX + w);
}
const rle = rleEncode(cropped);
return rle.length < cropped.length ? { b: [minX, minY, w, h], e: 'rle', v: rle } : { b: [minX, minY, w, h], e: 'raw', v: cropped };
}
function expandPlane(packed, size, emptyChar = '.') {
const total = Math.max(1, Number(size) || 1) ** 2;
const out = Array(total).fill(emptyChar);
if (!packed || !packed.b) return out.join('');
const [x0, y0, w, h] = packed.b.map((v) => Math.max(0, Number(v) || 0));
const value = packed.e === 'rle' ? rleDecode(packed.v) : String(packed.v || '');
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const src = y * w + x;
const dx = x0 + x;
const dy = y0 + y;
if (dx >= 0 && dy >= 0 && dx < size && dy < size && src < value.length) {
out[dy * size + dx] = value[src] || emptyChar;
}
}
}
return out.join('');
}
function packAsset(asset) {
const size = Math.max(1, Number(asset.size) || 16);
const category = asset.category === 'dynamic' ? 'dynamic' : 'static';
const right = normalizeEncodedPlane(asset.faces?.right || asset.pixels || '', size, '.');
const depth = asset.meta?.depthPixels ? normalizeEncodedPlane(asset.meta.depthPixels, size, '.') : '';
const lights = Array.isArray(asset.meta?.lightPixels)
? asset.meta.lightPixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || asset.meta?.lightColor || '']).filter((p) => p[2])
: [];
const meta = {};
if (depth && /1/.test(depth)) meta.d = cropPlane(depth, size, '.');
if (lights.length) meta.l = lights;
if (asset.meta?.lightColor) meta.lc = asset.meta.lightColor;
if (asset.meta?.door) meta.dr = [Number(asset.meta.door.x) || 0, Number(asset.meta.door.y) || 0];
return {
id: asset.id,
h: asset.contentHash || asset.hash || null,
n: asset.name || 'Untitled',
c: category,
t: asset.subtype || (category === 'dynamic' ? 'human' : 'other'),
s: size,
p: cropPlane(right, size, '.'),
f: category === 'dynamic' ? { l: 'mirror' } : null,
pa: asset.parentAssetId || null,
oa: asset.originalAssetId || null,
ca: asset.createdAt || Date.now(),
ua: asset.updatedAt || asset.createdAt || Date.now(),
au: asset.author || 'Local Artist',
m: Object.keys(meta).length ? meta : null
};
}
function unpackAsset(packed) {
if (!packed || !packed.id) return null;
const size = Math.max(1, Number(packed.s || packed.size) || 16);
const category = packed.c === 'dynamic' || packed.category === 'dynamic' ? 'dynamic' : 'static';
const pixels = expandPlane(packed.p, size, '.');
const metaPacked = packed.m || {};
const lightPixels = Array.isArray(metaPacked.l)
? metaPacked.l.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || metaPacked.lc || 'a' }))
: [];
const meta = {
hasLight: lightPixels.length > 0,
lightPixels,
lightColor: lightPixels.length > 0 ? (metaPacked.lc || lightPixels[0]?.c || null) : null,
depthPixels: metaPacked.d ? expandPlane(metaPacked.d, size, '.') : null,
door: Array.isArray(metaPacked.dr) ? { x: Number(metaPacked.dr[0]) || 0, y: Number(metaPacked.dr[1]) || 0 } : null
};
return {
id: packed.id,
name: packed.n || 'Untitled',
category,
subtype: packed.t || (category === 'dynamic' ? 'human' : 'other'),
size,
pixels,
faces: category === 'dynamic' ? { right: pixels, left: 'mirror' } : null,
parentAssetId: packed.pa || null,
originalAssetId: packed.oa || null,
createdAt: packed.ca || Date.now(),
updatedAt: packed.ua || packed.ca || Date.now(),
author: packed.au || 'Local Artist',
meta,
contentHash: packed.h || null
};
}
function packPlacement(item) {
return [item.id, item.assetId, Number(item.x) || 0, Number(item.y) || 0, item.placedAt || Date.now(), Number(item.version) || 1];
}
function unpackPlacement(row) {
if (!Array.isArray(row)) return row;
return { id: row[0], assetId: row[1], x: row[2], y: row[3], placedAt: row[4], version: row[5] || 1 };
}
function packDynamic(item) {
return [item.id, item.assetId, Number(item.homeX) || 0, Number(item.homeY) || 0, item.createdAt || Date.now(), Number(item.version) || 1];
}
function unpackDynamic(row) {
if (!Array.isArray(row)) return row;
return { id: row[0], assetId: row[1], homeX: row[2], homeY: row[3], createdAt: row[4], version: row[5] || 1 };
}
function compactState(state) {
return {
schema: 3,
format: FORMAT,
authorName: state.authorName || 'Local Artist',
assets: Array.isArray(state.assets) ? state.assets.map(packAsset) : [],
placed: Array.isArray(state.placed) ? state.placed.map(packPlacement) : [],
dynamicSummons: Array.isArray(state.dynamicSummons) ? state.dynamicSummons.map(packDynamic) : [],
objectVotes: state.objectVotes || {},
assetVotes: state.assetVotes || {},
hiddenAssets: state.hiddenAssets || {},
hiddenObjects: state.hiddenObjects || {},
eventLog: Array.isArray(state.eventLog) ? state.eventLog.slice(-EVENT_LOG_LIMIT) : [],
sync: state.sync || { lastEventId: null }
};
}
function expandState(input) {
if (!isCompactState(input)) return input;
return {
schema: 3,
authorName: input.authorName || 'Local Artist',
assets: input.assets.map(unpackAsset).filter(Boolean),
placed: (input.placed || []).map(unpackPlacement),
dynamicSummons: (input.dynamicSummons || []).map(unpackDynamic),
objectVotes: input.objectVotes || {},
assetVotes: input.assetVotes || {},
hiddenAssets: input.hiddenAssets || {},
hiddenObjects: input.hiddenObjects || {},
eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-EVENT_LOG_LIMIT) : [],
sync: input.sync || { lastEventId: null }
};
}
function compactSizeReport(state) {
const full = JSON.stringify({ ...state, schema: 3 });
const compact = JSON.stringify(compactState(state));
return {
fullBytes: full.length,
compactBytes: compact.length,
savedBytes: Math.max(0, full.length - compact.length),
savedPercent: full.length ? Math.round((1 - compact.length / full.length) * 1000) / 10 : 0,
assets: state.assets?.length || 0,
objects: (state.placed?.length || 0) + (state.dynamicSummons?.length || 0)
};
}
function assetManifest(state) {
return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, s: asset.size, c: asset.category, t: asset.subtype }));
}
function makeSnapshot(state, worldId = 'local-main') {
return {
schema: 3,
format: SNAPSHOT_FORMAT,
worldId,
createdAt: Date.now(),
manifest: assetManifest(state),
placed: (state.placed || []).map(packPlacement),
dynamicSummons: (state.dynamicSummons || []).map(packDynamic),
hiddenObjects: state.hiddenObjects || {}
};
}
function makeAssetBundle(state, assetIds) {
const wanted = new Set(assetIds || []);
const assets = (state.assets || []).filter((asset) => !wanted.size || wanted.has(asset.id)).map(packAsset);
return { schema: 3, format: ASSET_BUNDLE_FORMAT, createdAt: Date.now(), assets };
}
function unpackAssetBundle(bundle) {
if (!isAssetBundle(bundle)) return [];
return bundle.assets.map(unpackAsset).filter(Boolean);
}
function findMissingAssetIds(snapshot, knownAssetIds) {
const known = new Set(knownAssetIds || []);
return (snapshot?.manifest || []).map((asset) => asset.id).filter((id) => id && !known.has(id));
}
function makeEvent(type, payload = {}) {
return { id: `ev_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, type, at: Date.now(), ...payload };
}
function createAssetUpsertEvent(asset) {
return makeEvent('asset.upsert', { asset: packAsset(asset) });
}
function createObjectUpsertEvent(kind, object) {
return makeEvent('object.upsert', { kind, object: kind === 'dynamic' ? packDynamic(object) : packPlacement(object) });
}
function createObjectDeleteEvent(kind, objectId) {
return makeEvent('object.delete', { kind, objectId });
}
function applyEvent(state, event) {
if (!state || !event) return state;
if (event.type === 'asset.upsert' && event.asset) {
const asset = unpackAsset(event.asset);
if (!asset) return state;
const index = (state.assets || []).findIndex((a) => a.id === asset.id);
if (index >= 0) state.assets[index] = asset;
else (state.assets ||= []).unshift(asset);
}
if (event.type === 'object.upsert') {
if (event.kind === 'dynamic') {
const object = unpackDynamic(event.object);
const list = state.dynamicSummons ||= [];
const index = list.findIndex((item) => item.id === object.id);
if (index >= 0) list[index] = object;
else list.push(object);
} else {
const object = unpackPlacement(event.object);
const list = state.placed ||= [];
const index = list.findIndex((item) => item.id === object.id);
if (index >= 0) list[index] = object;
else list.push(object);
}
}
if (event.type === 'object.delete') {
const key = event.kind === 'dynamic' ? 'dynamicSummons' : 'placed';
state[key] = (state[key] || []).filter((item) => item.id !== event.objectId);
}
return state;
}
function openDb() {
if (!('indexedDB' in window)) return Promise.reject(new Error('IndexedDB is not available.'));
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(ASSET_STORE)) db.createObjectStore(ASSET_STORE, { keyPath: 'id' });
if (!db.objectStoreNames.contains(SNAPSHOT_STORE)) db.createObjectStore(SNAPSHOT_STORE, { keyPath: 'worldId' });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function cacheAssets(assets) {
if (!Array.isArray(assets) || !assets.length) return;
const db = await openDb();
await new Promise((resolve, reject) => {
const tx = db.transaction(ASSET_STORE, 'readwrite');
for (const asset of assets) tx.objectStore(ASSET_STORE).put(packAsset(asset));
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
}
async function readCachedAssets(assetIds) {
const ids = Array.from(assetIds || []);
if (!ids.length) return [];
const db = await openDb();
const rows = await Promise.all(ids.map((id) => new Promise((resolve) => {
const request = db.transaction(ASSET_STORE, 'readonly').objectStore(ASSET_STORE).get(id);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => resolve(null);
})));
db.close();
return rows.filter(Boolean).map(unpackAsset).filter(Boolean);
}
async function cacheSnapshot(snapshot) {
if (!snapshot?.worldId) return;
const db = await openDb();
await new Promise((resolve, reject) => {
const tx = db.transaction(SNAPSHOT_STORE, 'readwrite');
tx.objectStore(SNAPSHOT_STORE).put(snapshot);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
}
root.Phase2Sync = {
FORMAT,
SNAPSHOT_FORMAT,
ASSET_BUNDLE_FORMAT,
EVENT_LOG_LIMIT,
isCompactState,
isAssetBundle,
rleEncode,
rleDecode,
cropPlane,
expandPlane,
packAsset,
unpackAsset,
compactState,
expandState,
compactSizeReport,
assetManifest,
makeSnapshot,
makeAssetBundle,
unpackAssetBundle,
findMissingAssetIds,
makeEvent,
createAssetUpsertEvent,
createObjectUpsertEvent,
createObjectDeleteEvent,
applyEvent,
cacheAssets,
readCachedAssets,
cacheSnapshot
};
})();

View file

@ -1,11 +0,0 @@
(function () {
'use strict';
const root = window.PixelIslandModules ||= {};
root.RenderPipeline = {
run(stages, context) {
for (const stage of stages) stage(context);
}
};
})();

76
js/rotation-policy.js Normal file
View file

@ -0,0 +1,76 @@
(function () {
'use strict';
const root = window.PixelIslandModules ||= {};
const utils = root.CoreUtils || {};
const fnv1a = utils.fnv1a || ((value) => {
let hash = 0x811c9dc5;
for (const ch of String(value)) {
hash ^= ch.charCodeAt(0);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16).padStart(8, '0');
});
const DEFAULT_CONFIG = {
defaultDisplayLimit: 250,
newArrivalSlots: 150,
revivalSlots: 100,
publishLimitFirstDay: 5,
publishLimitTrusted: 10,
upvoteDelaySlots: 20,
downvoteAdvanceSlots: 25,
upvoteRankCap: 50
};
function objectPublicAt(object) {
return Number(object?.publishedAt || object?.placedAt || object?.createdAt || Date.now());
}
function entry(kind, object, baseIndex, votes, config = DEFAULT_CONFIG) {
const rawUp = Number(votes?.up) || 0;
const up = Math.min(rawUp, config.upvoteRankCap);
const down = Number(votes?.down) || 0;
return {
kind,
object,
id: object?.id,
assetId: object?.assetId,
publicAt: objectPublicAt(object),
baseIndex,
up,
rawUp,
down,
effectiveSlot: baseIndex + up * config.upvoteDelaySlots - down * config.downvoteAdvanceSlots
};
}
function seededScore(id, salt, rotationAt = Date.now()) {
const day = Math.floor((rotationAt || Date.now()) / (24 * 60 * 60 * 1000));
return parseInt(fnv1a(`${id}|${salt}|${day}`).slice(0, 8), 16) / 0xffffffff;
}
function buckets(entries, localLimit, config = DEFAULT_CONFIG, rotationAt = Date.now()) {
const newCap = Math.min(config.newArrivalSlots, localLimit);
const revivalCap = Math.max(0, Math.min(config.revivalSlots, localLimit - newCap));
const newest = entries
.slice()
.sort((a, b) => b.effectiveSlot - a.effectiveSlot || b.publicAt - a.publicAt || String(b.id).localeCompare(String(a.id)))
.slice(0, newCap);
const newestIds = new Set(newest.map((item) => item.id));
const revival = entries
.filter((item) => !newestIds.has(item.id))
.sort((a, b) => seededScore(b.id, 'revival', rotationAt) - seededScore(a.id, 'revival', rotationAt) || b.up - a.up || String(b.id).localeCompare(String(a.id)))
.slice(0, revivalCap);
return { newest, revival, entries, visibleIds: new Set([...newest, ...revival].map((item) => item.id)) };
}
function publishLimit(account, now = Date.now(), config = DEFAULT_CONFIG) {
if (!account?.createdAt) return 0;
return now - Number(account.createdAt) < 24 * 60 * 60 * 1000
? config.publishLimitFirstDay
: config.publishLimitTrusted;
}
root.RotationPolicy = { DEFAULT_CONFIG, objectPublicAt, entry, buckets, seededScore, publishLimit };
})();

103
js/state-index.js Normal file
View file

@ -0,0 +1,103 @@
(function () {
'use strict';
const root = window.PixelIslandModules ||= {};
function tileKey(x, y) {
return `${Math.round(x)},${Math.round(y)}`;
}
function pushBucket(map, key, item) {
const bucket = map.get(key);
if (bucket) bucket.push(item);
else map.set(key, [item]);
}
function build(state) {
const index = {
assetById: new Map(),
staticById: new Map(),
dynamicById: new Map(),
objectsById: new Map(),
placedByTile: new Map(),
dynamicByHomeTile: new Map(),
objectIdsByAssetId: new Map()
};
for (const asset of state.assets || []) {
if (asset?.id) index.assetById.set(asset.id, asset);
}
for (const placed of state.placed || []) {
if (!placed?.id) continue;
index.staticById.set(placed.id, placed);
index.objectsById.set(placed.id, { kind: 'static', object: placed });
pushBucket(index.placedByTile, tileKey(placed.x, placed.y), placed);
pushBucket(index.objectIdsByAssetId, placed.assetId, placed.id);
}
for (const summon of state.dynamicSummons || []) {
if (!summon?.id) continue;
index.dynamicById.set(summon.id, summon);
index.objectsById.set(summon.id, { kind: 'dynamic', object: summon });
pushBucket(index.dynamicByHomeTile, tileKey(summon.homeX, summon.homeY), summon);
pushBucket(index.objectIdsByAssetId, summon.assetId, summon.id);
}
return index;
}
function reduce(state, event, handlers = {}) {
if (!state || !event) return state;
switch (event.type) {
case 'asset.upsert': {
const asset = handlers.unpackAsset ? handlers.unpackAsset(event.asset) : event.asset;
if (!asset?.id) return state;
const list = state.assets ||= [];
const index = list.findIndex((item) => item.id === asset.id);
if (index >= 0) list[index] = asset;
else list.unshift(asset);
break;
}
case 'asset.delete': {
const assetId = event.assetId;
if (!assetId) return state;
const removed = new Set();
for (const item of state.placed || []) if (item.assetId === assetId) removed.add(item.id);
for (const item of state.dynamicSummons || []) if (item.assetId === assetId) removed.add(item.id);
state.assets = (state.assets || []).filter((asset) => asset.id !== assetId);
state.placed = (state.placed || []).filter((item) => item.assetId !== assetId);
state.dynamicSummons = (state.dynamicSummons || []).filter((item) => item.assetId !== assetId);
delete state.assetVotes?.[assetId];
delete state.hiddenAssets?.[assetId];
for (const id of removed) {
delete state.objectVotes?.[id];
delete state.hiddenObjects?.[id];
}
state.moderationReports = (state.moderationReports || []).filter((report) => !removed.has(report.objectId));
break;
}
case 'object.upsert': {
const object = handlers.unpackObject ? handlers.unpackObject(event.kind, event.object) : event.object;
if (!object?.id) return state;
const key = event.kind === 'dynamic' ? 'dynamicSummons' : 'placed';
const list = state[key] ||= [];
const index = list.findIndex((item) => item.id === object.id);
if (index >= 0) list[index] = object;
else list.push(object);
break;
}
case 'object.delete': {
const key = event.kind === 'dynamic' ? 'dynamicSummons' : 'placed';
state[key] = (state[key] || []).filter((item) => item.id !== event.objectId);
delete state.objectVotes?.[event.objectId];
delete state.hiddenObjects?.[event.objectId];
state.moderationReports = (state.moderationReports || []).filter((report) => report.objectId !== event.objectId);
break;
}
}
return state;
}
root.StateIndex = { build, reduce, tileKey };
})();

View file

@ -1,24 +0,0 @@
(function () {
'use strict';
const root = window.PixelIslandModules ||= {};
root.Vec2 = {
add(a, b) {
return { x: a.x + b.x, y: a.y + b.y };
},
sub(a, b) {
return { x: a.x - b.x, y: a.y - b.y };
},
scale(v, amount) {
return { x: v.x * amount, y: v.y * amount };
},
length(v) {
return Math.hypot(v.x, v.y);
},
normalize(v) {
const length = Math.hypot(v.x, v.y) || 1;
return { x: v.x / length, y: v.y / length };
}
};
})();

View file

@ -0,0 +1,12 @@
{
"display_limit": 250,
"newest_slots": 150,
"revival_slots": 100,
"revival_sample_size": 50,
"revival_pick_count": 20,
"upvote_delay_slots": 20,
"downvote_advance_slots": 25,
"upvote_rank_cap": 50,
"extreme_downvotes": 10,
"extreme_margin": 8
}

View file

@ -65,6 +65,33 @@ class RotationConfig:
extreme_downvotes: int = EXTREME_DOWNVOTES
extreme_margin: int = EXTREME_MARGIN
def __post_init__(self) -> None:
self.display_limit = max(0, int(self.display_limit))
self.newest_slots = max(0, int(self.newest_slots))
self.revival_slots = max(0, int(self.revival_slots))
if self.newest_slots + self.revival_slots > self.display_limit:
self.revival_slots = max(0, self.display_limit - self.newest_slots)
self.newest_slots = min(self.newest_slots, self.display_limit)
@classmethod
def from_mapping(cls, values: MutableMapping[str, Any] | None) -> "RotationConfig":
data = values or {}
allowed = set(cls.__dataclass_fields__)
clean = {key: int(value) for key, value in data.items() if key in allowed and value is not None}
return cls(**clean)
def load_config(path: Optional[Path], overrides: MutableMapping[str, Any] | None = None) -> RotationConfig:
data: Dict[str, Any] = {}
if path:
with path.open("r", encoding="utf-8") as f:
loaded = json.load(f)
if not isinstance(loaded, MutableMapping):
raise ValueError("Rotation config JSON root must be an object")
data.update(loaded)
data.update({k: v for k, v in (overrides or {}).items() if v is not None})
return RotationConfig.from_mapping(data)
def now_ms() -> int:
return int(time.time() * 1000)
@ -287,9 +314,10 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--write", action="store_true")
parser.add_argument("--out", type=Path)
parser.add_argument("--seed", type=int)
parser.add_argument("--display-limit", type=int, default=DISPLAY_LIMIT)
parser.add_argument("--newest-slots", type=int, default=NEWEST_SLOTS)
parser.add_argument("--revival-slots", type=int, default=REVIVAL_SLOTS)
parser.add_argument("--config", type=Path, help="Optional rotation policy JSON file.")
parser.add_argument("--display-limit", type=int)
parser.add_argument("--newest-slots", type=int)
parser.add_argument("--revival-slots", type=int)
parser.add_argument("--restore", nargs="*", default=None, help="Admin restore object IDs from permanent/violation hidden to rotation hidden.")
parser.add_argument("--hide-violation", nargs="*", default=None, help="Admin hide object IDs as violation_hidden.")
return parser.parse_args()
@ -298,7 +326,7 @@ def parse_args() -> argparse.Namespace:
def main() -> int:
args = parse_args()
state = load_json(args.world_json)
config = RotationConfig(display_limit=args.display_limit, newest_slots=args.newest_slots, revival_slots=args.revival_slots)
config = load_config(args.config, {"display_limit": args.display_limit, "newest_slots": args.newest_slots, "revival_slots": args.revival_slots})
summary: Dict[str, Any] = {}
if args.restore:
summary["restored"] = restore_permanent(state, args.restore)

View file

@ -0,0 +1,64 @@
import tempfile
import unittest
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from rotation_worker import (
ACTIVE,
HIDDEN_ROTATION,
VIOLATION_HIDDEN,
RotationConfig,
account_publish_limit,
apply_exhibition_cap,
hide_violation,
load_config,
restore_permanent,
)
class RotationWorkerTest(unittest.TestCase):
def test_account_publish_limit_changes_after_first_day(self):
now = 2_000_000_000
self.assertEqual(account_publish_limit({"id": "a", "createdAt": now}, now), 5)
self.assertEqual(account_publish_limit({"id": "a", "createdAt": now - 25 * 60 * 60 * 1000}, now), 10)
def test_exhibition_cap_hides_old_excess_objects(self):
state = {
"assets": [],
"placed": [
{"id": "old", "assetId": "a", "x": 1, "y": 1, "publishedAt": 10},
{"id": "new", "assetId": "b", "x": 2, "y": 2, "publishedAt": 20},
],
"dynamicSummons": [],
"objectVotes": {},
}
summary = apply_exhibition_cap(state, RotationConfig(display_limit=1, newest_slots=1, revival_slots=0), seed=1, now=30)
self.assertEqual(summary["active"], 1)
self.assertEqual(state["placed"][1]["status"], ACTIVE)
self.assertEqual(state["placed"][0]["status"], HIDDEN_ROTATION)
def test_display_limit_caps_slot_total(self):
config = RotationConfig(display_limit=3, newest_slots=5, revival_slots=5)
self.assertEqual(config.newest_slots + config.revival_slots, 3)
def test_violation_hide_and_restore(self):
state = {"placed": [{"id": "obj", "assetId": "a"}], "dynamicSummons": []}
self.assertEqual(hide_violation(state, ["obj"], now=1), ["obj"])
self.assertEqual(state["placed"][0]["status"], VIOLATION_HIDDEN)
self.assertEqual(restore_permanent(state, ["obj"], now=2), ["obj"])
self.assertEqual(state["placed"][0]["status"], HIDDEN_ROTATION)
def test_load_config_from_json_with_override(self):
with tempfile.TemporaryDirectory() as temp:
path = Path(temp) / "policy.json"
path.write_text('{"display_limit": 40, "newest_slots": 30}', encoding="utf-8")
config = load_config(path, {"revival_slots": 7})
self.assertEqual(config.display_limit, 40)
self.assertEqual(config.newest_slots, 30)
self.assertEqual(config.revival_slots, 7)
if __name__ == "__main__":
unittest.main()

166
server/test_world_policy.py Normal file
View file

@ -0,0 +1,166 @@
import unittest
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from world_policy import (
ACTIVE,
VIOLATION_HIDDEN,
apply_command,
apply_server_event,
can_delete_asset,
can_import_full_state,
can_modify_object,
make_dynamic_move_event,
make_world_phase_event,
)
def base_state():
return {
"worldMode": "shared",
"assets": [
{"id": "asset-a", "ownerAccountId": "alice", "author": "Alice", "version": 1},
{"id": "asset-b", "ownerAccountId": "bob", "author": "Bob", "version": 1},
],
"placed": [
{"id": "obj-a", "assetId": "asset-a", "ownerAccountId": "alice", "x": 1, "y": 1, "version": 1, "status": ACTIVE}
],
"dynamicSummons": [
{"id": "dyn-b", "assetId": "asset-b", "ownerAccountId": "bob", "homeX": 2, "homeY": 2, "version": 1, "status": ACTIVE}
],
"objectVotes": {},
"assetVotes": {},
"hiddenObjects": {},
"hiddenAssets": {},
"moderationReports": [],
"tombstones": {"assets": {}, "objects": {}},
"serverSync": {},
}
class WorldPolicyTest(unittest.TestCase):
def test_non_owner_cannot_delete_static_object(self):
state = base_state()
allowed, meta = can_modify_object(state, {"id": "bob"}, "obj-a")
self.assertFalse(allowed)
self.assertEqual(meta["reason"], "not_object_owner")
result = apply_command(state, {"id": "bob"}, {"type": "object.delete", "kind": "static", "objectId": "obj-a"}, at=100)
self.assertFalse(result["accepted"])
self.assertEqual(result["reason"], "not_object_owner")
self.assertEqual(len(state["placed"]), 1)
def test_non_owner_cannot_move_dynamic_object(self):
state = base_state()
command = {
"type": "object.move",
"kind": "dynamic",
"object": {"id": "dyn-b", "assetId": "asset-b", "homeX": 9, "homeY": 9, "version": 1},
}
result = apply_command(state, {"id": "alice"}, command, at=100)
self.assertFalse(result["accepted"])
self.assertEqual(result["reason"], "not_object_owner")
def test_owner_can_move_and_delete_own_object(self):
state = base_state()
move = apply_command(
state,
{"id": "bob"},
{"type": "object.move", "kind": "dynamic", "object": {"id": "dyn-b", "assetId": "asset-b", "homeX": 9, "homeY": 9, "version": 1}},
at=100,
)
self.assertTrue(move["accepted"])
self.assertTrue(apply_server_event(state, move["event"]))
self.assertEqual(state["dynamicSummons"][0]["homeX"], 9)
self.assertEqual(state["dynamicSummons"][0]["ownerAccountId"], "bob")
delete = apply_command(state, {"id": "bob"}, {"type": "object.delete", "kind": "dynamic", "objectId": "dyn-b"}, at=200)
self.assertTrue(delete["accepted"])
self.assertTrue(apply_server_event(state, delete["event"]))
self.assertEqual(state["dynamicSummons"], [])
self.assertIn("dyn-b", state["tombstones"]["objects"])
def test_admin_can_violation_hide_any_object(self):
state = base_state()
result = apply_command(state, {"id": "mod", "admin": True}, {"type": "admin.hide_violation", "objectId": "obj-a"}, at=100)
self.assertTrue(result["accepted"])
self.assertTrue(apply_server_event(state, result["event"]))
self.assertEqual(state["placed"][0]["status"], VIOLATION_HIDDEN)
self.assertTrue(state["placed"][0]["permanentHidden"])
def test_deleted_object_cannot_be_resurrected_by_stale_upsert(self):
state = base_state()
delete = apply_command(state, {"id": "alice"}, {"type": "object.delete", "kind": "static", "objectId": "obj-a"}, at=100)
self.assertTrue(apply_server_event(state, delete["event"]))
stale = {
"serverEventId": "sev_90_object_upsert",
"serverAt": 90,
"actorAccountId": "alice",
"type": "object.upsert",
"kind": "static",
"object": {"id": "obj-a", "assetId": "asset-a", "ownerAccountId": "alice", "x": 7, "y": 7, "version": 1},
"objectVersion": 1,
}
self.assertFalse(apply_server_event(state, stale))
self.assertEqual(state["placed"], [])
def test_asset_delete_cascades_and_tombstones_owned_objects(self):
state = base_state()
allowed, _ = can_delete_asset(state, {"id": "alice"}, "asset-a")
self.assertTrue(allowed)
result = apply_command(state, {"id": "alice"}, {"type": "asset.delete", "assetId": "asset-a"}, at=100)
self.assertTrue(result["accepted"])
self.assertTrue(apply_server_event(state, result["event"]))
self.assertNotIn("asset-a", [asset["id"] for asset in state["assets"]])
self.assertEqual(state["placed"], [])
self.assertIn("asset-a", state["tombstones"]["assets"])
self.assertIn("obj-a", state["tombstones"]["objects"])
self.assertEqual(len(state["dynamicSummons"]), 1)
def test_non_owner_can_delete_asset_and_all_placements(self):
state = base_state()
allowed, _ = can_delete_asset(state, {"id": "alice"}, "asset-b")
self.assertTrue(allowed)
result = apply_command(state, {"id": "alice"}, {"type": "asset.delete", "assetId": "asset-b"}, at=100)
self.assertTrue(result["accepted"])
self.assertTrue(apply_server_event(state, result["event"]))
self.assertNotIn("asset-b", [asset["id"] for asset in state["assets"]])
self.assertEqual(state["dynamicSummons"], [])
self.assertIn("asset-b", state["tombstones"]["assets"])
self.assertIn("dyn-b", state["tombstones"]["objects"])
def test_world_phase_event_updates_authoritative_clock(self):
state = base_state()
event = make_world_phase_event(at=600_000)
self.assertTrue(apply_server_event(state, event))
self.assertEqual(state["serverSync"]["clock"]["worldTimeMs"], 600_000)
self.assertEqual(state["serverSync"]["clock"]["dayMs"], 600_000)
self.assertEqual(state["serverSync"]["lastServerEventId"], event["serverEventId"])
def test_dynamic_move_event_is_server_authoritative(self):
state = base_state()
event = make_dynamic_move_event(state, "dyn-b", x=4.5, y=7.0, target_x=8.0, target_y=9.0, at=700, actor="server")
self.assertIsNotNone(event)
self.assertEqual(event["type"], "dynamic.move")
self.assertTrue(apply_server_event(state, event))
target = state["serverSync"]["dynamicTargets"]["dyn-b"]
self.assertEqual(target["targetX"], 8.0)
self.assertEqual(target["targetY"], 9.0)
self.assertEqual(state["dynamicSummons"][0]["serverState"]["x"], 4.5)
self.assertEqual(state["dynamicSummons"][0]["version"], 2)
def test_shared_full_state_import_is_rejected(self):
state = base_state()
allowed, meta = can_import_full_state(state, {"id": "alice"})
self.assertFalse(allowed)
self.assertEqual(meta["reason"], "shared_full_import_disabled")
local_state = {**state, "worldMode": "local"}
allowed, _ = can_import_full_state(local_state, {"id": "alice"})
self.assertTrue(allowed)
if __name__ == "__main__":
unittest.main()

431
server/world_policy.py Normal file
View file

@ -0,0 +1,431 @@
#!/usr/bin/env python3
"""Authoritative world mutation policy for Pixel Island.
The browser may preview local changes, but shared worlds should apply only the
server events produced here. This module focuses on ownership and destructive
mutation safety; rotation and moderation policy remain separate.
"""
from __future__ import annotations
import time
from copy import deepcopy
from typing import Any, Dict, Iterable, List, MutableMapping, Optional, Tuple
ACTIVE = "active"
VIOLATION_HIDDEN = "violation_hidden"
OBJECT_KEYS = {"static": "placed", "dynamic": "dynamicSummons"}
DAY_MS = 10 * 60 * 1000
def now_ms() -> int:
return int(time.time() * 1000)
def account_id(account: MutableMapping[str, Any] | None) -> str:
return str((account or {}).get("id") or "")
def is_admin(account: MutableMapping[str, Any] | None) -> bool:
return bool((account or {}).get("admin") or (account or {}).get("role") == "admin")
def normalize_tombstones(state: MutableMapping[str, Any]) -> MutableMapping[str, Any]:
tombstones = state.setdefault("tombstones", {})
tombstones.setdefault("assets", {})
tombstones.setdefault("objects", {})
return tombstones
def iter_objects(state: MutableMapping[str, Any]) -> Iterable[Tuple[str, MutableMapping[str, Any]]]:
for kind, key in OBJECT_KEYS.items():
for obj in state.get(key) or []:
if isinstance(obj, MutableMapping) and obj.get("id"):
yield kind, obj
def find_object(state: MutableMapping[str, Any], object_id: str, kind: Optional[str] = None) -> Tuple[Optional[str], Optional[MutableMapping[str, Any]]]:
kinds = [kind] if kind in OBJECT_KEYS else list(OBJECT_KEYS)
for item_kind in kinds:
for obj in state.get(OBJECT_KEYS[item_kind]) or []:
if str(obj.get("id") or "") == str(object_id):
return item_kind, obj
return None, None
def find_asset(state: MutableMapping[str, Any], asset_id: str) -> Optional[MutableMapping[str, Any]]:
for asset in state.get("assets") or []:
if isinstance(asset, MutableMapping) and str(asset.get("id") or "") == str(asset_id):
return asset
return None
def owner_of_object(state: MutableMapping[str, Any], obj: MutableMapping[str, Any]) -> str:
if obj.get("ownerAccountId"):
return str(obj.get("ownerAccountId"))
asset = find_asset(state, str(obj.get("assetId") or ""))
return str((asset or {}).get("ownerAccountId") or "")
def can_modify_object(state: MutableMapping[str, Any], account: MutableMapping[str, Any] | None, object_id: str) -> Tuple[bool, Dict[str, Any]]:
actor = account_id(account)
if not actor:
return False, {"reason": "account_required"}
kind, obj = find_object(state, object_id)
if not obj:
return False, {"reason": "object_not_found"}
owner = owner_of_object(state, obj)
if actor == owner or is_admin(account):
return True, {"reason": None, "kind": kind, "ownerAccountId": owner}
return False, {"reason": "not_object_owner", "ownerAccountId": owner}
def can_delete_asset(state: MutableMapping[str, Any], account: MutableMapping[str, Any] | None, asset_id: str) -> Tuple[bool, Dict[str, Any]]:
actor = account_id(account)
if not actor:
return False, {"reason": "account_required"}
asset = find_asset(state, asset_id)
if not asset:
return False, {"reason": "asset_not_found"}
owner = str(asset.get("ownerAccountId") or "")
return True, {"reason": None, "ownerAccountId": owner}
def is_object_tombstoned(state: MutableMapping[str, Any], object_id: str, version: int = 0) -> bool:
tomb = normalize_tombstones(state)["objects"].get(str(object_id))
return bool(tomb and int(tomb.get("version") or 0) >= int(version or 0))
def is_asset_tombstoned(state: MutableMapping[str, Any], asset_id: str, version: int = 0) -> bool:
tomb = normalize_tombstones(state)["assets"].get(str(asset_id))
return bool(tomb and int(tomb.get("version") or 0) >= int(version or 0))
def _server_event(event_type: str, actor: str, payload: Dict[str, Any], at: Optional[int] = None) -> Dict[str, Any]:
stamp = at or now_ms()
return {
"serverEventId": f"sev_{stamp}_{event_type.replace('.', '_')}",
"serverAt": stamp,
"actorAccountId": actor,
"type": event_type,
**payload,
}
def _accepted(event: Dict[str, Any]) -> Dict[str, Any]:
return {"accepted": True, "event": event}
def phase_key_for_time(world_time_ms: int, day_ms: int = DAY_MS) -> str:
progress = (int(world_time_ms) % int(day_ms or DAY_MS)) / float(day_ms or DAY_MS)
if progress < 0.10:
return "pre_dawn"
if progress < 0.20:
return "sunrise"
if progress < 0.60:
return "day"
if progress < 0.72:
return "sunset"
return "night"
def make_world_phase_event(at: Optional[int] = None, day_ms: int = DAY_MS) -> Dict[str, Any]:
"""Create the authoritative day/night clock event emitted by the server."""
stamp = at or now_ms()
cycle = int(day_ms or DAY_MS)
world_time_ms = int(stamp)
progress = (world_time_ms % cycle) / float(cycle)
return _server_event(
"world.phase",
"server",
{
"worldTimeMs": world_time_ms,
"dayMs": cycle,
"phase": {"key": phase_key_for_time(world_time_ms, cycle), "progress": progress},
},
stamp,
)
def make_dynamic_move_event(
state: MutableMapping[str, Any],
object_id: str,
x: float,
y: float,
target_x: Optional[float] = None,
target_y: Optional[float] = None,
at: Optional[int] = None,
actor: str = "server",
) -> Optional[Dict[str, Any]]:
"""Create an authoritative dynamic-object movement event.
Clients may ask for a target, but shared-world dynamic positions are only
changed after this server event is applied.
"""
stamp = at or now_ms()
kind, obj = find_object(state, object_id, "dynamic")
if kind != "dynamic" or not obj:
return None
next_object = deepcopy(dict(obj))
target = {
"objectId": str(object_id),
"x": float(x),
"y": float(y),
"targetX": float(target_x if target_x is not None else x),
"targetY": float(target_y if target_y is not None else y),
"homeX": float(next_object.get("homeX", x)),
"homeY": float(next_object.get("homeY", y)),
"serverAt": stamp,
}
next_object["serverState"] = target
next_object["version"] = int(next_object.get("version") or 0) + 1
return _server_event(
"dynamic.move",
actor or "server",
{
"objectId": str(object_id),
"object": next_object,
"x": target["x"],
"y": target["y"],
"targetX": target["targetX"],
"targetY": target["targetY"],
"homeX": target["homeX"],
"homeY": target["homeY"],
"objectVersion": next_object["version"],
},
stamp,
)
def _rejected(reason: str, **extra: Any) -> Dict[str, Any]:
return {"accepted": False, "reason": reason, **extra}
def _copy_owned_asset(asset: MutableMapping[str, Any], owner: str) -> Dict[str, Any]:
copied = deepcopy(dict(asset))
copied["ownerAccountId"] = owner
copied.setdefault("author", owner)
copied.setdefault("version", 1)
return copied
def _copy_owned_object(obj: MutableMapping[str, Any], owner: str) -> Dict[str, Any]:
copied = deepcopy(dict(obj))
copied["ownerAccountId"] = owner
copied["version"] = int(copied.get("version") or 0) + 1
copied.setdefault("status", ACTIVE)
return copied
def apply_command(state: MutableMapping[str, Any], account: MutableMapping[str, Any] | None, command: MutableMapping[str, Any], at: Optional[int] = None) -> Dict[str, Any]:
"""Validate a client command and return a server event without mutating state."""
actor = account_id(account)
if not actor:
return _rejected("account_required")
command_type = str(command.get("type") or "")
if command_type == "asset.create":
asset = command.get("asset")
if not isinstance(asset, MutableMapping) or not asset.get("id"):
return _rejected("invalid_asset")
if is_asset_tombstoned(state, str(asset["id"]), int(asset.get("version") or 1)):
return _rejected("asset_tombstoned")
return _accepted(_server_event("asset.upsert", actor, {"asset": _copy_owned_asset(asset, actor)}, at))
if command_type in {"object.publish", "object.move"}:
kind = str(command.get("kind") or "static")
obj = command.get("object")
if kind not in OBJECT_KEYS or not isinstance(obj, MutableMapping) or not obj.get("id"):
return _rejected("invalid_object")
asset = find_asset(state, str(obj.get("assetId") or ""))
if not asset:
return _rejected("asset_not_found")
_, existing = find_object(state, str(obj["id"]), kind)
if existing:
allowed, meta = can_modify_object(state, account, str(obj["id"]))
if not allowed:
return _rejected(meta["reason"], **{k: v for k, v in meta.items() if k != "reason"})
owner = owner_of_object(state, existing)
else:
if str(asset.get("ownerAccountId") or "") != actor and not is_admin(account):
return _rejected("not_asset_owner", ownerAccountId=asset.get("ownerAccountId"))
owner = actor
next_object = _copy_owned_object(obj, owner)
if is_object_tombstoned(state, str(next_object["id"]), int(next_object.get("version") or 1)):
return _rejected("object_tombstoned")
return _accepted(_server_event("object.upsert", actor, {"kind": kind, "object": next_object, "objectVersion": next_object["version"]}, at))
if command_type == "object.delete":
object_id = str(command.get("objectId") or "")
kind = str(command.get("kind") or "")
allowed, meta = can_modify_object(state, account, object_id)
if not allowed:
return _rejected(meta["reason"], **{k: v for k, v in meta.items() if k != "reason"})
found_kind, obj = find_object(state, object_id, kind if kind in OBJECT_KEYS else None)
version = int((obj or {}).get("version") or 0) + 1
tombstone = {"id": object_id, "deletedAt": at or now_ms(), "deletedBy": actor, "version": version}
return _accepted(_server_event("object.delete", actor, {"kind": found_kind, "objectId": object_id, "objectVersion": version, "tombstone": tombstone}, at))
if command_type == "asset.delete":
asset_id = str(command.get("assetId") or "")
allowed, meta = can_delete_asset(state, account, asset_id)
if not allowed:
return _rejected(meta["reason"], **{k: v for k, v in meta.items() if k != "reason"})
asset = find_asset(state, asset_id) or {}
version = int(asset.get("version") or 0) + 1
object_tombstones: List[Dict[str, Any]] = []
for _, obj in iter_objects(state):
if str(obj.get("assetId") or "") == asset_id:
object_tombstones.append({"id": str(obj["id"]), "deletedAt": at or now_ms(), "deletedBy": actor, "version": int(obj.get("version") or 0) + 1})
tombstone = {"id": asset_id, "deletedAt": at or now_ms(), "deletedBy": actor, "version": version}
return _accepted(_server_event("asset.delete", actor, {"assetId": asset_id, "assetVersion": version, "tombstone": tombstone, "objectTombstones": object_tombstones}, at))
if command_type == "admin.hide_violation":
if not is_admin(account):
return _rejected("admin_required")
object_id = str(command.get("objectId") or "")
kind, obj = find_object(state, object_id)
if not obj:
return _rejected("object_not_found")
next_object = deepcopy(dict(obj))
next_object["status"] = VIOLATION_HIDDEN
next_object["permanentHidden"] = True
next_object["hiddenReason"] = "moderation_violation"
next_object["version"] = int(next_object.get("version") or 0) + 1
return _accepted(_server_event("object.upsert", actor, {"kind": kind, "object": next_object, "objectVersion": next_object["version"]}, at))
return _rejected("unknown_command")
def apply_server_event(state: MutableMapping[str, Any], event: MutableMapping[str, Any]) -> bool:
"""Apply only server-issued events. Returns True when state changed."""
if not event or not event.get("serverEventId") or not event.get("serverAt"):
return False
normalize_tombstones(state)
event_type = str(event.get("type") or "")
if event_type == "world.phase":
clock = state.setdefault("serverSync", {}).setdefault("clock", {})
day_ms = int(event.get("dayMs") or DAY_MS)
world_time_ms = int(event.get("worldTimeMs") or event.get("serverNow") or event.get("serverAt") or now_ms())
clock.clear()
clock.update({
"worldTimeMs": world_time_ms,
"syncedAt": int(event.get("serverAt") or now_ms()),
"dayMs": day_ms,
"phase": event.get("phase") or {"key": phase_key_for_time(world_time_ms, day_ms), "progress": (world_time_ms % day_ms) / float(day_ms)},
"serverEventId": event["serverEventId"],
})
state["serverSync"]["lastServerEventId"] = event["serverEventId"]
return True
if event_type == "dynamic.move":
object_id = str(event.get("objectId") or event.get("dynamicId") or "")
obj = event.get("object")
if not object_id and isinstance(obj, MutableMapping):
object_id = str(obj.get("id") or "")
if not object_id:
return False
object_version = int((obj or {}).get("version") or event.get("objectVersion") or 1) if isinstance(obj, MutableMapping) else int(event.get("objectVersion") or 1)
if is_object_tombstoned(state, object_id, object_version):
return False
target = {
"objectId": object_id,
"x": float(event.get("x", (obj or {}).get("serverState", {}).get("x", (obj or {}).get("homeX", 0))) if isinstance(obj, MutableMapping) else event.get("x", 0)),
"y": float(event.get("y", (obj or {}).get("serverState", {}).get("y", (obj or {}).get("homeY", 0))) if isinstance(obj, MutableMapping) else event.get("y", 0)),
"targetX": float(event.get("targetX", event.get("x", 0))),
"targetY": float(event.get("targetY", event.get("y", 0))),
"homeX": float(event.get("homeX", (obj or {}).get("homeX", 0)) if isinstance(obj, MutableMapping) else event.get("homeX", 0)),
"homeY": float(event.get("homeY", (obj or {}).get("homeY", 0)) if isinstance(obj, MutableMapping) else event.get("homeY", 0)),
"serverAt": int(event.get("serverAt") or now_ms()),
}
server_sync = state.setdefault("serverSync", {})
server_sync.setdefault("dynamicTargets", {})[object_id] = target
if isinstance(obj, MutableMapping) and obj.get("id"):
rows = state.setdefault("dynamicSummons", [])
next_object = deepcopy(dict(obj))
next_object["serverState"] = target
index = next((i for i, item in enumerate(rows) if str(item.get("id") or "") == object_id), -1)
if index >= 0:
rows[index] = next_object
else:
rows.append(next_object)
server_sync["lastServerEventId"] = event["serverEventId"]
return True
if event_type == "asset.upsert":
asset = event.get("asset")
if not isinstance(asset, MutableMapping) or not asset.get("id"):
return False
if is_asset_tombstoned(state, str(asset["id"]), int(asset.get("version") or 1)):
return False
assets = state.setdefault("assets", [])
index = next((i for i, item in enumerate(assets) if item.get("id") == asset.get("id")), -1)
if index >= 0:
assets[index] = deepcopy(dict(asset))
else:
assets.insert(0, deepcopy(dict(asset)))
state.setdefault("serverSync", {})["lastServerEventId"] = event["serverEventId"]
return True
if event_type == "object.upsert":
obj = event.get("object")
kind = str(event.get("kind") or "static")
if kind not in OBJECT_KEYS or not isinstance(obj, MutableMapping) or not obj.get("id"):
return False
if is_object_tombstoned(state, str(obj["id"]), int(obj.get("version") or event.get("objectVersion") or 1)):
return False
rows = state.setdefault(OBJECT_KEYS[kind], [])
index = next((i for i, item in enumerate(rows) if item.get("id") == obj.get("id")), -1)
if index >= 0:
rows[index] = deepcopy(dict(obj))
else:
rows.append(deepcopy(dict(obj)))
state.setdefault("serverSync", {})["lastServerEventId"] = event["serverEventId"]
return True
if event_type == "object.delete":
object_id = str(event.get("objectId") or "")
kind = str(event.get("kind") or "")
if not object_id or kind not in OBJECT_KEYS:
return False
tombstone = event.get("tombstone") or {"id": object_id, "deletedAt": event["serverAt"], "deletedBy": event.get("actorAccountId"), "version": event.get("objectVersion") or 1}
state["tombstones"]["objects"][object_id] = tombstone
state[OBJECT_KEYS[kind]] = [obj for obj in state.get(OBJECT_KEYS[kind]) or [] if str(obj.get("id") or "") != object_id]
state.setdefault("objectVotes", {}).pop(object_id, None)
state.setdefault("hiddenObjects", {}).pop(object_id, None)
state.setdefault("serverSync", {}).setdefault("dynamicTargets", {}).pop(object_id, None)
state["moderationReports"] = [report for report in state.get("moderationReports") or [] if report.get("objectId") != object_id]
state.setdefault("serverSync", {})["lastServerEventId"] = event["serverEventId"]
return True
if event_type == "asset.delete":
asset_id = str(event.get("assetId") or "")
if not asset_id:
return False
state["tombstones"]["assets"][asset_id] = event.get("tombstone") or {"id": asset_id, "deletedAt": event["serverAt"], "deletedBy": event.get("actorAccountId"), "version": event.get("assetVersion") or 1}
for tombstone in event.get("objectTombstones") or []:
if tombstone.get("id"):
state["tombstones"]["objects"][str(tombstone["id"])] = tombstone
removed_object_ids = {str(t.get("id")) for t in event.get("objectTombstones") or [] if t.get("id")}
state["assets"] = [asset for asset in state.get("assets") or [] if str(asset.get("id") or "") != asset_id]
for key in OBJECT_KEYS.values():
state[key] = [obj for obj in state.get(key) or [] if str(obj.get("id") or "") not in removed_object_ids]
state.setdefault("assetVotes", {}).pop(asset_id, None)
state.setdefault("hiddenAssets", {}).pop(asset_id, None)
dynamic_targets = state.setdefault("serverSync", {}).setdefault("dynamicTargets", {})
for object_id in removed_object_ids:
state.setdefault("objectVotes", {}).pop(object_id, None)
state.setdefault("hiddenObjects", {}).pop(object_id, None)
dynamic_targets.pop(object_id, None)
state["moderationReports"] = [report for report in state.get("moderationReports") or [] if report.get("objectId") not in removed_object_ids]
state.setdefault("serverSync", {})["lastServerEventId"] = event["serverEventId"]
return True
return False
def can_import_full_state(state: MutableMapping[str, Any], account: MutableMapping[str, Any] | None) -> Tuple[bool, Dict[str, Any]]:
if state.get("worldMode") == "shared":
return False, {"reason": "shared_full_import_disabled"}
return True, {"reason": None}

1873
styles.css

File diff suppressed because it is too large Load diff

View file

@ -1,901 +0,0 @@
:root {
--ink: #243044;
--muted: #6f7b91;
--paper: #fff7e8;
--panel: rgba(255, 248, 232, .94);
--panel-solid: #fff8e9;
--panel-2: #ffeec9;
--line: #2d3c50;
--line-soft: rgba(45, 60, 80, .18);
--pink: #ff85b3;
--pink-dark: #f05f98;
--mint: #73d6a4;
--sky: #77c9ff;
--sun: #ffd66e;
--danger: #ff6b6b;
--shadow: 6px 6px 0 rgba(34, 45, 63, .14);
--hard-shadow: 4px 4px 0 rgba(34, 45, 63, .22);
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
color: var(--ink);
background: #92d8ff;
font-family: "Courier New", "Monaco", "Lucida Console", ui-monospace, monospace;
overflow: hidden;
}
button, input, select, textarea { font: inherit; }
button { -webkit-tap-highlight-color: transparent; }
.app {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
}
#worldCanvas {
display: block;
width: 100vw;
height: 100vh;
image-rendering: pixelated;
cursor: grab;
background: #8cd4ff;
}
#worldCanvas.dragging { cursor: grabbing; }
#worldCanvas.placeCursor { cursor: copy; }
#worldCanvas.eraseCursor { cursor: not-allowed; }
.hud {
position: absolute;
z-index: 5;
background: var(--panel);
border: 2px solid var(--line);
border-radius: 0;
box-shadow: var(--shadow);
backdrop-filter: blur(8px);
}
.topHud {
top: 14px;
left: 14px;
right: 14px;
min-height: 70px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
pointer-events: none;
}
.logo {
font-weight: 900;
letter-spacing: .04em;
font-size: 20px;
}
.logo span {
display: inline-block;
background: var(--pink);
border: 2px solid var(--line);
padding: 1px 6px;
margin-left: 6px;
font-size: 12px;
transform: rotate(-2deg);
}
.subline {
margin-top: 3px;
color: var(--muted);
font-size: 12px;
}
.authorCard {
width: 190px;
border: 2px solid var(--line);
background: #fffdf5;
padding: 6px 8px;
pointer-events: auto;
}
.authorCard span {
display: block;
font-size: 10px;
font-weight: 900;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--muted);
}
.authorCard input {
margin-top: 3px;
padding: 5px 6px;
border-width: 2px;
background: #fffaf0;
}
.clockCard {
width: 230px;
border: 2px solid var(--line);
background: #fffdf5;
padding: 8px 10px;
}
.phaseLabel {
font-weight: 900;
letter-spacing: .06em;
text-transform: uppercase;
font-size: 13px;
}
.phaseBar {
margin-top: 6px;
height: 10px;
background: #dfe8f0;
border: 2px solid var(--line);
overflow: hidden;
}
.phaseBar span {
display: block;
width: 0%;
height: 100%;
background: linear-gradient(90deg, #ffd66e, #77c9ff, #6862e8);
}
.clockHint {
margin-top: 4px;
color: var(--muted);
font-size: 11px;
}
.edgeAdd {
position: absolute;
z-index: 8;
left: 0;
top: 50%;
width: 56px;
height: 86px;
transform: translateY(-50%);
border: 3px solid var(--line);
border-left: 0;
border-radius: 0 18px 18px 0;
background: var(--pink);
color: white;
font-size: 42px;
font-weight: 900;
line-height: 1;
box-shadow: var(--hard-shadow);
cursor: pointer;
}
.edgeAdd:hover { background: var(--pink-dark); }
.edgeAdd:active { transform: translateY(calc(-50% + 2px)); box-shadow: 2px 2px 0 rgba(34,45,63,.22); }
.toolHud {
right: 14px;
top: 116px;
display: grid;
grid-template-columns: 1fr;
gap: 7px;
padding: 9px;
}
.iconButton,
button,
.ghostButton,
.tab,
.tool,
.segmented button {
border: 2px solid var(--line);
border-radius: 0;
background: #fffdf5;
color: var(--ink);
padding: 8px 11px;
font-weight: 850;
cursor: pointer;
box-shadow: 3px 3px 0 rgba(36, 48, 68, .16);
transition: transform .05s, box-shadow .05s, background .12s;
}
button:hover,
.ghostButton:hover,
.tab:hover,
.tool:hover,
.segmented button:hover { background: #fff1c8; }
button:active,
.ghostButton:active,
.tab:active,
.tool:active,
.segmented button:active {
transform: translate(2px, 2px);
box-shadow: 1px 1px 0 rgba(36, 48, 68, .16);
}
button.active,
.tab.active,
.tool.active,
.segmented button.active {
background: var(--sun);
box-shadow: inset 0 0 0 2px rgba(255,255,255,.4), 3px 3px 0 rgba(36,48,68,.18);
}
button.primary {
background: var(--mint);
color: #163a2a;
}
button.secondary { background: var(--panel-2); }
button.danger, .tool.danger, .iconButton.danger { background: #ffe4e4; color: #833232; }
button.danger.active, .iconButton.danger.active { background: var(--danger); color: #fffdf5; }
.divider {
height: 2px;
background: var(--line);
opacity: .22;
margin: 2px 0;
}
.infoHud {
right: 14px;
bottom: 14px;
width: min(360px, calc(100vw - 28px));
padding: 12px;
}
.selectedAsset {
font-weight: 900;
padding-bottom: 8px;
border-bottom: 2px dashed rgba(45,60,80,.25);
}
.tileInfo {
white-space: pre-wrap;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
margin-top: 8px;
}
.studioDrawer {
position: absolute;
z-index: 7;
top: 0;
left: 0;
width: min(500px, calc(100vw - 20px));
height: 100vh;
background: var(--panel-solid);
border-right: 3px solid var(--line);
box-shadow: 10px 0 0 rgba(36, 48, 68, .12);
transform: translateX(-104%);
transition: transform .18s ease-out;
display: grid;
grid-template-rows: auto auto 1fr;
}
.studioDrawer.open { transform: translateX(0); }
.drawerHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 10px 12px 8px;
border-bottom: 3px solid var(--line);
background: #ffe6f0;
}
.drawerHead h1 {
margin: 0;
font-size: 24px;
font-weight: 950;
}
.drawerHead p {
margin: 3px 0 0;
color: var(--muted);
font-size: 12px;
}
.ghostButton {
width: 36px;
height: 36px;
padding: 0;
font-size: 24px;
line-height: 1;
}
.tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
padding: 8px;
background: #fff2d0;
border-bottom: 3px solid var(--line);
}
.tab { padding: 8px 5px; font-size: 13px; }
.drawerBody {
overflow-y: auto;
padding: 8px;
background:
linear-gradient(45deg, rgba(255,255,255,.4) 25%, transparent 25%) 0 0/18px 18px,
var(--panel-solid);
}
.tabPanel { display: none; }
.tabPanel.active { display: block; }
.compactNameField { margin-bottom: 5px; }
.roleHintInline { margin: 0 0 10px; }
.card {
background: #fffdf5;
border: 2px solid var(--line);
box-shadow: 4px 4px 0 rgba(36,48,68,.12);
padding: 9px;
margin-bottom: 8px;
}
.stack > * + * { margin-top: 10px; }
.cardTitle {
font-weight: 950;
text-transform: uppercase;
letter-spacing: .06em;
font-size: 12px;
color: #3c4b60;
}
.field {
display: block;
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
input, select, textarea {
display: block;
width: 100%;
margin-top: 5px;
border: 2px solid var(--line);
border-radius: 0;
background: #fffaf0;
color: var(--ink);
padding: 9px 10px;
outline: none;
}
input:focus, select:focus, textarea:focus { background: #fff; box-shadow: 0 0 0 3px rgba(119,201,255,.35); }
textarea { resize: vertical; }
input[type="color"] { height: 39px; padding: 3px; }
input[type="checkbox"] { width: auto; margin: 0 8px 0 0; }
.twoCols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.segmented {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.segmented button { font-size: 12px; padding: 8px 5px; }
.hint {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
margin: 0;
}
.editorCard { padding-bottom: 10px; }
.editorTop {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 9px;
}
.sideSwitcher {
display: flex;
gap: 5px;
align-items: center;
}
.sideSwitcher button { padding: 6px 7px; font-size: 11px; }
.toolRow {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
align-items: center;
margin-bottom: 8px;
}
.editorToolRow { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.editorHistoryRow { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.editorMoveRow { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.editorFileRow { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.toolRow input { margin: 0; }
.tool { padding: 7px 5px; font-size: 11px; }
button:disabled, .tool:disabled { opacity: .45; cursor: not-allowed; transform: none; box-shadow: none; }
#paintCanvas {
display: block;
width: min(100%, 330px);
margin: 0 auto;
aspect-ratio: 1 / 1;
image-rendering: pixelated;
border: 3px solid var(--line);
background:
linear-gradient(45deg, #f4ebdc 25%, transparent 25%) 0 0/16px 16px,
linear-gradient(45deg, transparent 75%, #f4ebdc 75%) 0 0/16px 16px,
#fffaf0;
cursor: crosshair;
user-select: none;
touch-action: none;
}
.compactSettings .twoCols { align-items: center; }
.toggleLine {
color: var(--ink);
font-size: 13px;
font-weight: 850;
display: flex;
align-items: center;
}
.behaviorNote {
border: 2px dashed rgba(45,60,80,.22);
padding: 8px;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.actionRow {
display: flex;
gap: 9px;
margin: 12px 0;
}
.actionRow.wrap { flex-wrap: wrap; }
.actionRow button { flex: 1; }
.lineageNote { min-height: 18px; color: var(--muted); font-size: 12px; }
.assetList { display: grid; gap: 10px; }
.assetCard {
display: grid;
grid-template-columns: 68px 1fr;
gap: 10px;
border: 2px solid var(--line);
background: #fff9e8;
padding: 8px;
box-shadow: 3px 3px 0 rgba(36,48,68,.12);
}
.assetCard.selected { background: #e6ffef; }
.assetPreview {
width: 64px;
height: 64px;
image-rendering: pixelated;
background: #fff3d9;
border: 2px solid var(--line);
}
.assetMeta strong { display: block; font-size: 14px; }
.assetMeta span { display: block; margin-top: 3px; color: var(--muted); font-size: 12px; line-height: 1.3; }
.assetActions {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 8px;
}
.assetActions button { padding: 5px 7px; font-size: 11px; }
.modeGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.modeGrid button { padding: 8px 5px; font-size: 12px; }
.legend {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
font-size: 12px;
color: var(--muted);
}
.legend span { display: flex; align-items: center; gap: 6px; }
.legend i {
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid var(--line);
}
.legend .water { background: #67b5d9; }
.legend .sand { background: #ead493; }
.legend .grass { background: #8bce76; }
.legend .highland { background: #a7be80; }
#dataBox { min-height: 160px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; }
.toast {
position: absolute;
z-index: 20;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
background: #fffdf5;
border: 3px solid var(--line);
box-shadow: var(--hard-shadow);
padding: 10px 14px;
font-weight: 900;
}
[hidden] { display: none !important; }
@media (max-width: 760px) {
.topHud { align-items: flex-start; flex-direction: column; right: 70px; }
.clockCard, .authorCard { width: 100%; }
.toolHud { top: auto; bottom: 122px; right: 10px; }
.infoHud { left: 10px; right: 10px; width: auto; }
.studioDrawer { width: calc(100vw - 18px); }
.toolRow { grid-template-columns: 44px repeat(3, minmax(0, 1fr)); }
}
/* refinements */
.toolHud {
top: 142px;
right: 18px;
}
.edgeAdd {
top: calc(50% + 24px);
width: 62px;
height: 92px;
background: linear-gradient(180deg, #ff97c2, #ff75aa);
}
.studioDrawer {
width: min(520px, calc(100vw - 28px));
}
.drawerHead { background: #ffe1ef; }
.drawerBody {
background:
linear-gradient(90deg, rgba(255,255,255,.42) 0 1px, transparent 1px) 0 0/20px 20px,
linear-gradient(0deg, rgba(255,255,255,.42) 0 1px, transparent 1px) 0 0/20px 20px,
#fff8e9;
}
.card {
border-width: 3px;
box-shadow: 5px 5px 0 rgba(36,48,68,.13);
}
.heroEditor {
background: #fffdf7;
border-color: #26364c;
}
#paintCanvas {
margin-bottom: 10px;
max-height: min(42vh, 330px);
}
.toolRow {
grid-template-columns: repeat(5, minmax(0, 1fr));
margin-top: 0;
}
.paletteGrid {
display: grid;
grid-template-columns: repeat(13, 1fr);
gap: 3px;
margin: 6px 0 6px;
padding: 6px;
border: 2px solid rgba(36,48,68,.28);
background: #fff6df;
}
.paletteSwatch {
position: relative;
aspect-ratio: 1 / 1;
min-height: 18px;
border: 2px solid rgba(36,48,68,.38);
box-shadow: 2px 2px 0 rgba(36,48,68,.13);
cursor: pointer;
}
.paletteSwatch.active {
outline: 3px solid #ff7aad;
outline-offset: 1px;
border-color: #26364c;
}
.paletteSwatch::after {
content: attr(data-code);
position: absolute;
right: 2px;
bottom: 0;
color: rgba(20,26,38,.52);
font-size: 7px;
font-weight: 900;
text-shadow: 0 1px 0 rgba(255,255,255,.7);
}
.quickSetupBar { margin-bottom: 6px; }
.settingsCard .segmented { margin-top: 2px; }
.markerRow {
display: grid;
gap: 6px;
}
.markerChip {
border: 2px dashed rgba(36,48,68,.25);
background: #fff6df;
padding: 8px;
font-size: 12px;
color: var(--muted);
font-weight: 800;
}
.summonCard {
background: linear-gradient(180deg, #fffdf5, #fff2d7);
}
.finishActions {
display: grid;
grid-template-columns: 1.35fr .8fr .6fr;
}
.bigPrimary {
min-height: 48px;
font-size: 14px;
background: linear-gradient(180deg, #89e4ae, #65d596) !important;
}
.sideSwitcher button:first-child.active::after,
.sideSwitcher button:nth-child(2).active::after {
content: "";
display: inline-block;
width: 6px;
}
.spawnPop {
pointer-events: none;
}
.selectedAsset { font-weight: 900; padding-bottom: 8px; border-bottom: 2px dashed rgba(45,60,80,.25); }
@media (max-width: 720px) {
.toolHud { top: auto; bottom: 126px; right: 10px; }
.finishActions { grid-template-columns: 1fr; }
.paletteGrid { grid-template-columns: repeat(8, 1fr); }
}
.paletteGrid button { width: 100%; aspect-ratio: 1 / 1; padding: 0; min-height: 22px; }
.sideSwitcher button { white-space: nowrap; }
.heroEditor .hint { line-height: 1.35; }
.compactEditorTop { margin-bottom: 4px; min-height: 0; }
body, button, input, select, textarea { font-smooth: never; -webkit-font-smoothing: none; }
/* latest layout fixes */
.studioDrawer { width: min(600px, calc(100vw - 24px)); }
.paintLayout {
display: grid;
grid-template-columns: minmax(220px, 360px) 1fr;
align-items: start;
gap: 10px;
margin: 4px 0 8px;
}
#paintCanvas {
width: 100%;
max-width: 360px;
max-height: none;
margin: 0;
}
.paletteGrid {
margin: 0;
grid-template-columns: repeat(6, 1fr);
align-content: start;
gap: 4px;
max-height: 360px;
overflow: hidden;
}
.paletteSwatch, .paletteGrid button {
min-height: 20px;
}
.sideSwitcher { justify-content: flex-start; }
.authorCard span { white-space: nowrap; }
@media (max-width: 720px) {
.paintLayout { grid-template-columns: 1fr; }
.paletteGrid { grid-template-columns: repeat(10, 1fr); max-height: none; }
}
/* Selection bubble + outline controls */
.palettePanel { display: grid; gap: 8px; }
.selectionBubble {
position: absolute;
left: 0;
top: 0;
z-index: 9;
min-width: 126px;
max-width: 210px;
padding: 8px;
background: #fffdf5;
border: 3px solid var(--line);
box-shadow: 4px 4px 0 rgba(36,48,68,.20);
pointer-events: auto;
text-align: center;
}
.selectionBubble::after {
content: "";
position: absolute;
left: 50%;
bottom: -10px;
width: 14px;
height: 14px;
background: #fffdf5;
border-right: 3px solid var(--line);
border-bottom: 3px solid var(--line);
transform: translateX(-50%) rotate(45deg);
}
.bubbleName {
font-size: 12px;
font-weight: 950;
line-height: 1.2;
word-break: break-word;
}
.bubbleAuthor {
margin-top: 2px;
font-size: 10px;
color: var(--muted);
font-weight: 800;
}
.bubbleVotes {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 6px;
align-items: center;
margin-top: 6px;
}
.bubbleVotes button {
padding: 3px 7px;
min-width: 30px;
font-size: 11px;
}
#voteScore {
font-weight: 950;
font-size: 12px;
min-width: 20px;
}
.bubbleRemixFrom, .bubbleRemixCount {
margin-top: 3px;
color: var(--muted);
font-size: 10px;
font-weight: 800;
}
.bubbleActions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 5px;
margin-top: 6px;
}
.bubbleActions button {
padding: 4px 6px;
font-size: 10px;
}
.bubbleVotes button.mutedVote, .assetActions button.mutedAction {
opacity: .45;
}
.bubbleVotes button.activeVote {
background: var(--sun);
}
/* Pixel-ish UI refinements */
body, button, input, select, textarea {
font-family: "Courier New", "Lucida Console", Monaco, monospace;
letter-spacing: .02em;
}
button, .tab, .tool, .iconButton, .cardTitle, .logo {
text-transform: uppercase;
font-weight: 900;
}
.libraryHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.hiddenAssetPanel {
border: 2px dashed rgba(36,48,68,.35);
background: #fff3d6;
padding: 8px;
}
.compactAssetList .assetCard { opacity: .82; }
.librarySectionTitle {
margin: 12px 0 6px;
padding: 5px 7px;
background: #ffe6f0;
border: 2px solid var(--line);
box-shadow: 3px 3px 0 rgba(36,48,68,.12);
font-size: 12px;
font-weight: 900;
}
.advancedRow {
display: grid;
grid-template-columns: max-content 1fr;
gap: 8px;
align-items: center;
margin-top: 8px;
}
.advancedOnly.active { background: #bde9ff; }
.depthLegend {
font-size: 10px;
color: var(--muted);
}
/* Simplified current UI */
body, button, input, select, textarea {
font-family: "MS Gothic", "Osaka-Mono", "DotumChe", "Courier New", ui-monospace, monospace;
letter-spacing: .02em;
-webkit-font-smoothing: none;
text-rendering: geometricPrecision;
}
.toolRow { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.editorHistoryRow { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.advancedRow {
grid-template-columns: max-content max-content max-content 1fr;
gap: 6px;
}
.librarySectionTitle {
display: block;
width: 100%;
text-align: left;
margin: 10px 0 6px;
cursor: pointer;
}
.librarySectionTitle::after {
content: " click: genre";
float: right;
color: var(--muted);
font-size: 10px;
}
.assetSectionGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
max-height: 360px;
overflow-y: auto;
padding-right: 4px;
}
.libraryEmptyNote {
color: var(--muted);
font-size: 12px;
border: 2px dashed rgba(36,48,68,.2);
padding: 8px;
background: #fff9e8;
}
.assetSectionGrid .assetCard {
grid-template-columns: 54px 1fr;
gap: 8px;
}
.assetSectionGrid .assetPreview {
width: 52px;
height: 52px;
}
.assetSectionGrid .assetActions button { padding: 4px 5px; font-size: 10px; }
@media (max-width: 720px) {
.assetSectionGrid { grid-template-columns: 1fr; max-height: none; }
.toolRow { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.editorHistoryRow { grid-template-columns: 1fr 1fr; }
.advancedRow { grid-template-columns: 1fr 1fr 1fr; }
}
.syncStats {
padding: 8px 10px;
border-radius: 12px;
background: rgba(20, 25, 32, 0.06);
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}