Compare commits

...

4 commits

Author SHA1 Message Date
67a7477242 server 2026-06-02 02:35:22 +09:00
2501a4c975 update 2026-06-02 00:05:03 +09:00
5b5848f1dc updateeeed 2026-06-01 22:48:15 +09:00
89a32dc28e updated 2026-06-01 21:17:09 +09:00
15 changed files with 9715 additions and 1000 deletions

194
README.md
View file

@ -1,25 +1,189 @@
# Pixel Island Summoner
Local prototype. Open `index.html` with Live Server, or run:
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.
## 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.
- Night rendering is darker.
- Object picking now uses sprite-sized, per-opaque-pixel hit testing, so transparent pixels are not clickable.
- The CSS font stack now tries `3x5 MT Pixel Font` first. The font file itself is not bundled.
## Phase 5 changes
- 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
- Static sprites now anchor closer to the center of their tile instead of the front corner.
- Object selection is pixel-accurate: transparent sprite cells no longer select the object, and tile inspection does not select hidden/transparent areas.
- Right-clicking the map clears selection silently.
- Hidden panel now lists both hidden library assets and hidden map objects. Objects hidden by Report appear there and can be restored with Show again.
- Report now opens a reason dialog with Cancel and Report + Hide actions.
- Library cards include Place/Move, which makes dynamic human assets easier to reposition.
- Default bundled pixel art was replaced with a new set: Hill Cottage, Pine Cluster, Shell Rock, Wave Skiff, Fisher Kid, Moss Cat, Koi Fish, and Cloud Gull.
- Added a static Ship role/subtype. Ships can be placed on water, float subtly, and periodically emit ring ripples.
- Local storage key was bumped to load the new default set in this prototype build.
## Phase 5 polish
- Night lights now draw after the darkness overlay and use fixed bright cores/halos, so lamp pixels visually ignore the night darkening pass.
- The sky color now transitions quickly around dawn and dusk. Day and night remain mostly stable instead of drifting continuously.
## Phase 5 Effects
- Nearby objects receive a faint surface glow from adjacent light sources.
- Light cores are less dazzling, while night ambient darkness is lifted slightly.
- Land-moving dynamic sprites emit small terrain-colored step particles.
## Visual FX follow-up
- Step particles now vary in hue / saturation / lightness around the terrain color.
- Nature assets shed tiny drifting leaf/petal dots.
- Coast foam uses two slow-rotating porous bluish-white layers.
- Depth now affects phase-aware sprite shading for sun/moon exposure.
- Added toggles for lights, particles, and the day-night cycle.
## Phase 5 UI / particle follow-up
- Coastal foam no longer uses cut-out holes.
- 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.
## Rotation policy build
This build adds count-based island rotation.
- Default island display cap: 250 objects.
- The local display cap is adjustable from Data > Visual 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.
- Replacing or republishing a rotation-hidden object updates its `publishedAt` and consumes one quota slot.
Server-side periodic rotation code is in `server/rotation_worker.py`.
Example:
```bash
python3 -m http.server 5500
python server/rotation_worker.py world.json --write
```
Then open `http://localhost:5500`.
It performs:
## Current controls
1. Permanent-hide marking for extremely downvoted objects.
2. Randomly samples 50 hidden historical objects.
3. Republishes the top 20 by upvote count.
4. Reapplies the active display cap.
5. Adds permanent-hidden objects to `adminReviewQueue` for deletion checks.
- Open Pixel Studio with the large `+` button.
- Left drag on the pixel canvas: draw.
- Right drag or middle drag on the pixel canvas: pan.
- Mouse wheel on the pixel canvas: zoom.
- Use `Light` to paint light cells with the selected palette color.
- Use `Set Outline` to apply the selected palette color as a thin sprite outline.
- Click map objects to select them and show their bubble.
## Notes
## Local-only rotation note
This build does not call a server from the browser. The island display cap is applied client-side, and objects over the cap are kept in local save but not drawn. Server recirculation / permanent hiding only happens when `server/rotation_worker.py` is run against exported world JSON.
- Data is saved in `localStorage`.
- The vote bubble is local only.
- Server-side moderation is not implemented yet; see the TODO comment in `app.js` near `voteSelected()`.
## Exhibition Policy Revision
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.
- 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.
- 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.
### Server worker
```bash
python server/rotation_worker.py world.json --write
python server/rotation_worker.py world.json --restore object123 --write
python server/rotation_worker.py world.json --hide-violation object123 --write
```
The Python worker is the authoritative production policy. The browser mirrors it only for local prototype use.
### Compression lab
```bash
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.

58
README.md.bak Normal file
View file

@ -0,0 +1,58 @@
# 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.

3829
app.js

File diff suppressed because it is too large Load diff

3774
app.js.bak Normal file

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,11 @@
<canvas id="worldCanvas" aria-label="Island map"></canvas>
<button id="openEditor" class="edgeAdd" title="Open Pixel Studio">+</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>
</div>
<header class="hud topHud">
<div>
@ -20,6 +25,8 @@
<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>
@ -33,26 +40,18 @@
<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="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>
<button id="settingsToggle" class="iconButton" title="World settings">Settings</button>
</aside>
<section id="settingsPanel" class="hud settingsPanel" hidden aria-label="World settings">
<label class="toggleLine">
<input id="lightingToggle" type="checkbox" checked />
Lighting
</label>
</section>
<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">x</button>
<button id="closeEditor" class="ghostButton" title="Close">×</button>
</div>
<nav class="tabs" aria-label="Studio tabs">
@ -67,10 +66,10 @@
<div class="quickSetupBar twoCols">
<label class="field">Canvas size
<select id="assetSize">
<option value="8" selected>8 x 8</option>
<option value="16">16 x 16</option>
<option value="32">32 x 32</option>
<option value="64">64 x 64</option>
<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
@ -79,6 +78,7 @@
<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>
@ -89,8 +89,8 @@
</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>
<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>
@ -98,37 +98,48 @@
<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="outlineControls">
<button id="setOutline" class="tool" type="button">Set Outline</button>
<button id="clearOutline" class="tool" type="button">No Outline</button>
<span id="outlineStatus">Outline: none</span>
</div>
</div>
</div>
<div class="toolRow">
<button id="toolBrush" class="tool active">Draw</button>
<button id="toolErase" class="tool">Erase</button>
<button id="toolFill" class="tool">Fill</button>
<button id="toolPick" class="tool">Pick</button>
<button id="toolLight" class="tool">Light</button>
<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 editorActions">
<button id="undoPaint" class="tool" type="button">Undo</button>
<button id="redoPaint" class="tool" type="button">Redo</button>
<button id="mirrorLeft" class="tool" type="button">Mirror Left</button>
<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 both pixels and light cells.</div>
<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 + Summon on Map</button>
<button id="saveAsset" class="secondary">Save only</button>
<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>
</div>
</div>
@ -137,8 +148,16 @@
<section id="tab-library" class="tabPanel">
<div class="card stack">
<div class="cardTitle">Library</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 class="libraryHead">
<div class="cardTitle">Library</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>
<div id="hiddenAssetPanel" class="hiddenAssetPanel" hidden>
<div class="cardTitle">Hidden</div>
<div id="hiddenAssetList" class="assetList compactAssetList"></div>
</div>
<div id="likedCodex" class="likedCodex"></div>
<div id="assetList" class="assetList"></div>
</div>
</section>
@ -146,14 +165,47 @@
<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>
<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 JSON</button>
<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>
<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>
<label class="checkRow"><input id="settingParticles" type="checkbox" checked /> <span>Particles & ambient FX</span></label>
<label class="checkRow"><input id="settingDayNight" type="checkbox" checked /> <span>Day / night cycle</span></label>
<label class="field displayLimitField">Island display cap
<input id="displayLimit" type="number" min="25" max="500" step="25" value="250" />
</label>
</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>
@ -164,22 +216,41 @@
<div id="bubbleRemixFrom" class="bubbleRemixFrom" hidden></div>
<div id="bubbleRemixCount" class="bubbleRemixCount">Remixed: 0</div>
<div class="bubbleVotes">
<button id="voteUp" type="button">Up</button>
<button id="voteUp" type="button"></button>
<span id="voteScore">0</span>
<button id="voteDown" type="button">Down</button>
<button id="voteDown" type="button"></button>
</div>
<div class="bubbleActions">
<button id="bubbleRemix" type="button">Remix</button>
<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="reportDialog" class="reportDialog" hidden>
<div class="reportCard" role="dialog" aria-modal="true" aria-labelledby="reportTitle">
<div id="reportTitle" class="cardTitle">Report object</div>
<p class="hint">Select a reason for reporting <strong id="reportObjectName">this object</strong>.</p>
<label class="field">Reason
<select id="reportReason">
<option value="inappropriate">Inappropriate / sexual</option>
<option value="harassment">Harassment / hate</option>
<option value="spam">Spam / flooding</option>
<option value="copyright">Copyright / stolen work</option>
<option value="other">Other</option>
</select>
</label>
<div class="actionRow wrap reportActions">
<button id="reportSubmit" class="danger" type="button">Report + Hide</button>
<button id="reportCancel" type="button">Cancel</button>
</div>
</div>
</div>
</div>
<script src="./js/vector.js"></script>
<script src="./js/lighting.js"></script>
<script src="./js/render-pipeline.js"></script>
<script src="./js/editor-actions.js"></script>
<script src="./js/phase2-sync.js"></script>
<script src="./app.js"></script>
</body>
</html>

203
index.html.bak Normal file
View file

@ -0,0 +1,203 @@
<!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>

View file

@ -3,7 +3,40 @@
const root = window.PixelIslandModules ||= {};
function clonePixels(pixels) {
return Array.isArray(pixels) ? [...pixels] : [];
}
function floodFill(sourcePixels, size, x, y, colorCode) {
const pixels = clonePixels(sourcePixels);
const target = pixels[y * size + x] || null;
const replacement = colorCode || null;
if (target === replacement) return { pixels, changed: false, count: 0, cells: [] };
const stack = [[x, y]];
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 ((pixels[index] || null) !== target) continue;
pixels[index] = replacement;
cells.push({ x: cx, y: cy });
stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
}
return { pixels, changed: cells.length > 0, count: cells.length, cells };
}
function pickColor(sourcePixels, size, x, y) {
if (!Array.isArray(sourcePixels)) return null;
if (x < 0 || y < 0 || x >= size || y >= size) return null;
return sourcePixels[y * size + x] || null;
}
root.EditorActions = {
clonePixels,
floodFill,
pickColor,
apply(snapshot, pushHistory, mutate) {
pushHistory(snapshot());
mutate();

429
js/phase2-sync.js Normal file
View file

@ -0,0 +1,429 @@
(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 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])
: [];
const meta = {};
if (depth && /[1\-]/.test(depth)) meta.d = cropPlane(depth, size, '.');
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];
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 particlePixels = Array.isArray(metaPacked.pt)
? metaPacked.pt.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || 'a' }))
: [];
const meta = {
hasLight: lightPixels.length > 0,
lightPixels,
lightColor: lightPixels.length > 0 ? (metaPacked.lc || lightPixels[0]?.c || null) : null,
hasParticles: particlePixels.length > 0,
particlePixels,
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) {
const meta = {};
if (item.publishedAt) meta.pu = item.publishedAt;
if (item.status && item.status !== 'active') meta.st = item.status;
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' };
}
function packDynamic(item) {
const meta = {};
if (item.publishedAt) meta.pu = item.publishedAt;
if (item.status && item.status !== 'active') meta.st = item.status;
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' };
}
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 || {},
moderationReports: Array.isArray(state.moderationReports) ? state.moderationReports : [],
guardrails: state.guardrails || null,
settings: state.settings || null,
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 }
};
}
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 || {},
moderationReports: Array.isArray(input.moderationReports) ? input.moderationReports : [],
guardrails: input.guardrails || null,
settings: input.settings || null,
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 }
};
}
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
};
})();

402
js/phase2-sync.js.bak Normal file
View file

@ -0,0 +1,402 @@
(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
};
})();

Binary file not shown.

Binary file not shown.

42
server/compression_lab.py Normal file
View file

@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Compare stronger compression candidates for Pixel Island asset JSON.
Usage:
python server/compression_lab.py exported_world.json
This does not change production data. It reports approximate sizes for raw JSON,
minified JSON, gzip, zlib, and brotli if the optional `brotli` module is installed.
For browser/server interchange, prefer: compact JSON -> gzip/brotli at HTTP layer.
"""
from __future__ import annotations
import argparse, gzip, json, zlib
from pathlib import Path
try:
import brotli # type: ignore
except Exception: # pragma: no cover
brotli = None
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument('json_file', type=Path)
args = ap.parse_args()
data = json.loads(args.json_file.read_text(encoding='utf-8'))
pretty = json.dumps(data, ensure_ascii=False, indent=2).encode('utf-8')
mini = json.dumps(data, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
rows = [
('pretty_json', len(pretty)),
('minified_json', len(mini)),
('gzip_9', len(gzip.compress(mini, compresslevel=9))),
('zlib_9', len(zlib.compress(mini, level=9))),
]
if brotli:
rows.append(('brotli_11', len(brotli.compress(mini, quality=11))))
base = len(pretty) or 1
for name, size in rows:
print(f'{name:14} {size:10d} bytes {size/base:6.1%} of pretty JSON')
return 0
if __name__ == '__main__':
raise SystemExit(main())

316
server/rotation_worker.py Normal file
View file

@ -0,0 +1,316 @@
#!/usr/bin/env python3
"""Authoritative server-side exhibition rotation policy for Pixel Island.
This worker treats assets as permanent library works and placements as temporary
island exhibition objects. The browser may preview the same policy locally, but
production should accept this worker/database layer as the source of truth.
Policy:
- Account required to publish.
- First 24h: 5 public placements/hour. Day 2+: 10 placements/hour.
- Island exhibition cap: 250 active objects.
- 150 slots are newest/recent-publication slots.
- 100 slots are random revival slots.
- Newest and revival buckets are selected independently; duplicates are removed.
- Upvote rank effect is capped at +50 votes.
- Downvotes advance rotation-out. Extreme downvote hides use the same user-facing
wording as capacity rotation, unless an admin marks a violation.
- Reports are local-user hides in the client. Server-side moderation creates
permanent_hidden / violation_hidden only after admin/policy action.
- permanent_hidden can be restored by an admin.
"""
from __future__ import annotations
import argparse
import json
import random
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, MutableMapping, Optional, Tuple
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
FIRST_DAY_LIMIT = 5
TRUSTED_LIMIT = 10
ONE_HOUR_MS = 60 * 60 * 1000
ONE_DAY_MS = 24 * ONE_HOUR_MS
EXTREME_DOWNVOTES = 10
EXTREME_MARGIN = 8
ACTIVE = "active"
HIDDEN_ROTATION = "hidden_rotation"
PERMANENT_HIDDEN = "permanent_hidden"
VIOLATION_HIDDEN = "violation_hidden"
PUBLIC_REASON_ROTATION = "island_exhibition_full"
PUBLIC_REASON_VIOLATION = "moderation_violation"
@dataclass
class RotationConfig:
display_limit: int = DISPLAY_LIMIT
newest_slots: int = NEWEST_SLOTS
revival_slots: int = REVIVAL_SLOTS
revival_sample_size: int = REVIVAL_SAMPLE_SIZE
revival_pick_count: int = REVIVAL_PICK_COUNT
upvote_delay_slots: int = UPVOTE_DELAY_SLOTS
downvote_advance_slots: int = DOWNVOTE_ADVANCE_SLOTS
upvote_rank_cap: int = UPVOTE_RANK_CAP
extreme_downvotes: int = EXTREME_DOWNVOTES
extreme_margin: int = EXTREME_MARGIN
def now_ms() -> int:
return int(time.time() * 1000)
def object_id(obj: MutableMapping[str, Any]) -> str:
return str(obj.get("id") or "")
def public_at(obj: MutableMapping[str, Any]) -> int:
return int(obj.get("publishedAt") or obj.get("placedAt") or obj.get("createdAt") or 0)
def author_id_from_account(account: MutableMapping[str, Any] | None) -> str:
return str((account or {}).get("id") or "")
def account_publish_limit(account: MutableMapping[str, Any] | None, now: Optional[int] = None) -> int:
if not account or not account.get("createdAt"):
return 0
now = now or now_ms()
created = int(account.get("createdAt") or now)
return FIRST_DAY_LIMIT if now - created < ONE_DAY_MS else TRUSTED_LIMIT
def can_publish(state: MutableMapping[str, Any], account: MutableMapping[str, Any] | None, now: Optional[int] = None) -> Tuple[bool, Dict[str, Any]]:
"""API helper. Production publish endpoints should call this before accepting a placement."""
now = now or now_ms()
account_id = author_id_from_account(account)
limit = account_publish_limit(account, now)
if not account_id or limit <= 0:
return False, {"reason": "account_required", "used": 0, "limit": 0}
log = [entry for entry in state.get("publishLog") or [] if now - int(entry.get("at") or 0) < ONE_DAY_MS]
used = sum(1 for entry in log if entry.get("author") == account_id and now - int(entry.get("at") or 0) < ONE_HOUR_MS)
state["publishLog"] = log
return used < limit, {"reason": None if used < limit else "quota_exceeded", "used": used, "limit": limit}
def record_publish(state: MutableMapping[str, Any], account: MutableMapping[str, Any], obj: MutableMapping[str, Any], kind: str, action: str, now: Optional[int] = None) -> None:
now = now or now_ms()
obj["publishedAt"] = now
obj["status"] = ACTIVE
obj.pop("hiddenReason", None)
obj.pop("hiddenAt", None)
log = state.setdefault("publishLog", [])
log.append({"at": now, "author": author_id_from_account(account), "kind": kind, "objectId": object_id(obj), "assetId": obj.get("assetId"), "action": action})
state["publishLog"] = log[-10000:]
def iter_objects(state: MutableMapping[str, Any]) -> Iterable[Tuple[str, MutableMapping[str, Any]]]:
for obj in state.get("placed") or []:
if isinstance(obj, MutableMapping) and obj.get("id"):
yield "static", obj
for obj in state.get("dynamicSummons") or []:
if isinstance(obj, MutableMapping) and obj.get("id"):
yield "dynamic", obj
def vote_counts(state: MutableMapping[str, Any], obj_id: str) -> Tuple[int, int]:
votes = (state.get("objectVotes") or {}).get(obj_id) or {}
return int(votes.get("up") or 0), int(votes.get("down") or 0)
def is_moderation_hidden(obj: MutableMapping[str, Any]) -> bool:
return obj.get("status") in {PERMANENT_HIDDEN, VIOLATION_HIDDEN} or obj.get("permanentHidden") is True
def eligible_for_exhibition(obj: MutableMapping[str, Any]) -> bool:
return not is_moderation_hidden(obj)
def rotation_entry(state: MutableMapping[str, Any], kind: str, obj: MutableMapping[str, Any], base_index: int, config: RotationConfig) -> Dict[str, Any]:
up_raw, down = vote_counts(state, object_id(obj))
up_rank = min(up_raw, config.upvote_rank_cap)
return {
"kind": kind,
"object": obj,
"id": object_id(obj),
"publicAt": public_at(obj),
"baseIndex": base_index,
"up": up_raw,
"upRank": up_rank,
"down": down,
"effectiveSlot": base_index + up_rank * config.upvote_delay_slots - down * config.downvote_advance_slots,
}
def rotation_entries(state: MutableMapping[str, Any], config: RotationConfig) -> List[Dict[str, Any]]:
pairs = [(kind, obj) for kind, obj in iter_objects(state) if eligible_for_exhibition(obj)]
pairs.sort(key=lambda pair: (public_at(pair[1]), object_id(pair[1])))
return [rotation_entry(state, kind, obj, i, config) for i, (kind, obj) in enumerate(pairs)]
def deterministic_random_score(obj_id: str, seed: int) -> float:
rng = random.Random(f"{seed}:{obj_id}")
return rng.random()
def select_exhibition_buckets(state: MutableMapping[str, Any], config: RotationConfig, seed: Optional[int] = None) -> Dict[str, List[Dict[str, Any]]]:
seed = seed if seed is not None else int(state.get("lastRotationAt") or now_ms()) // ONE_DAY_MS
entries = rotation_entries(state, config)
newest = sorted(entries, key=lambda e: (e["effectiveSlot"], e["publicAt"], e["id"]), reverse=True)[:config.newest_slots]
newest_ids = {e["id"] for e in newest}
revival_pool = [e for e in entries if e["id"] not in newest_ids]
# Periodic server revival: sample up to 50 previous/excess objects, then take 20 with
# the best capped score, while keeping randomness in the sample.
historical = [e for e in revival_pool if e["object"].get("status") == HIDDEN_ROTATION]
rng = random.Random(seed)
sample = rng.sample(historical, min(config.revival_sample_size, len(historical))) if historical else []
picked = sorted(sample, key=lambda e: (e["upRank"], e["upRank"] - e["down"], e["publicAt"]), reverse=True)[:config.revival_pick_count]
picked_ids = {e["id"] for e in picked}
rest = [e for e in revival_pool if e["id"] not in picked_ids]
random_rest = sorted(rest, key=lambda e: (deterministic_random_score(e["id"], seed), e["upRank"] - e["down"]), reverse=True)
revival = (picked + random_rest)[:config.revival_slots]
return {"newest": newest, "revival": revival, "entries": entries}
def apply_extreme_downvote_policy(state: MutableMapping[str, Any], config: RotationConfig, now: Optional[int] = None) -> List[Dict[str, Any]]:
"""Hide extreme downvote cases from the island with the same public text as capacity rotation.
This is not a violation decision. It stays reversible and is separate from admin
violation hiding.
"""
now = now or now_ms()
changed: List[Dict[str, Any]] = []
for kind, obj in iter_objects(state):
if is_moderation_hidden(obj):
continue
up, down = vote_counts(state, object_id(obj))
if down >= config.extreme_downvotes and down - up >= config.extreme_margin:
obj["status"] = HIDDEN_ROTATION
obj["hiddenReason"] = PUBLIC_REASON_ROTATION
obj["hiddenAt"] = now
changed.append({"objectId": object_id(obj), "assetId": obj.get("assetId"), "kind": kind, "reason": "extreme_downvotes_as_rotation", "up": up, "down": down})
return changed
def apply_exhibition_cap(state: MutableMapping[str, Any], config: RotationConfig, seed: Optional[int] = None, now: Optional[int] = None) -> Dict[str, Any]:
now = now or now_ms()
buckets = select_exhibition_buckets(state, config, seed=seed)
visible_ids = {e["id"] for e in buckets["newest"] + buckets["revival"]}
newly_active = newly_hidden = 0
for entry in buckets["entries"]:
obj = entry["object"]
if entry["id"] in visible_ids:
if obj.get("status") != ACTIVE:
newly_active += 1
obj["status"] = ACTIVE
obj.pop("hiddenReason", None)
obj.pop("hiddenAt", None)
else:
if obj.get("status") != HIDDEN_ROTATION:
newly_hidden += 1
obj["status"] = HIDDEN_ROTATION
obj["hiddenReason"] = PUBLIC_REASON_ROTATION
obj["hiddenAt"] = now
return {"active": len(visible_ids), "newest": len(buckets["newest"]), "revival": len(buckets["revival"]), "rotationHidden": max(0, len(buckets["entries"]) - len(visible_ids)), "newlyActive": newly_active, "newlyHidden": newly_hidden}
def hide_violation(state: MutableMapping[str, Any], object_ids: List[str], now: Optional[int] = None) -> List[str]:
now = now or now_ms()
hidden: List[str] = []
for _, obj in iter_objects(state):
if object_id(obj) in object_ids:
obj["status"] = VIOLATION_HIDDEN
obj["permanentHidden"] = True
obj["hiddenReason"] = PUBLIC_REASON_VIOLATION
obj["hiddenAt"] = now
hidden.append(object_id(obj))
return hidden
def restore_permanent(state: MutableMapping[str, Any], object_ids: List[str], now: Optional[int] = None) -> List[str]:
"""Admin restore for permanent/violation hidden placements."""
now = now or now_ms()
restored: List[str] = []
for _, obj in iter_objects(state):
if object_id(obj) in object_ids and is_moderation_hidden(obj):
obj["status"] = HIDDEN_ROTATION
obj["permanentHidden"] = False
obj["hiddenReason"] = PUBLIC_REASON_ROTATION
obj["hiddenAt"] = now
restored.append(object_id(obj))
return restored
def run_rotation(state: MutableMapping[str, Any], config: RotationConfig, seed: Optional[int] = None) -> Dict[str, Any]:
now = now_ms()
extreme = apply_extreme_downvote_policy(state, config, now=now)
cap = apply_exhibition_cap(state, config, seed=seed, now=now)
state["lastRotationAt"] = now
state["rotationPolicy"] = {
"displayLimit": config.display_limit,
"newestSlots": config.newest_slots,
"revivalSlots": config.revival_slots,
"upvoteRankCap": config.upvote_rank_cap,
"serverAuthoritative": True,
}
return {"extremeDownvoteHiddenAsRotation": extreme, "cap": cap, "lastRotationAt": now}
def load_json(path: Path) -> MutableMapping[str, Any]:
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, MutableMapping):
raise ValueError("World JSON root must be an object")
return data
def save_json(path: Path, data: MutableMapping[str, Any]) -> None:
with path.open("w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run Pixel Island exhibition rotation policy on a world JSON file.")
parser.add_argument("world_json", type=Path)
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("--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()
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)
summary: Dict[str, Any] = {}
if args.restore:
summary["restored"] = restore_permanent(state, args.restore)
if args.hide_violation:
summary["violationHidden"] = hide_violation(state, args.hide_violation)
if not args.restore and not args.hide_violation:
summary = run_rotation(state, config, seed=args.seed)
print(json.dumps(summary, ensure_ascii=False, indent=2))
if args.write or args.out:
save_json(args.out or args.world_json, state)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -25,17 +25,12 @@ body {
margin: 0;
color: var(--ink);
background: #92d8ff;
font-family: "Courier New", "Monaco", "Lucida Console", ui-monospace, monospace;
font-family: "3x5 MT Pixel Font", "Press Start 2P", "PixelMplus10", "DotGothic16", "Courier New", "Monaco", "Lucida Console", ui-monospace, monospace;
overflow: hidden;
}
button, input, select, textarea { font: inherit; }
button { -webkit-tap-highlight-color: transparent; }
button:disabled {
cursor: not-allowed;
opacity: .48;
transform: none;
}
.app {
position: relative;
@ -183,7 +178,7 @@ button:disabled {
.edgeAdd:hover { background: var(--pink-dark); }
.edgeAdd:active { transform: translateY(calc(-50% + 2px)); box-shadow: 2px 2px 0 rgba(34,45,63,.22); }
.toolHud {
.toolHud {
right: 14px;
top: 116px;
display: grid;
@ -192,14 +187,6 @@ button:disabled {
padding: 9px;
}
.settingsPanel {
top: 142px;
right: 126px;
z-index: 6;
padding: 10px 12px;
min-width: 150px;
}
.iconButton,
button,
.ghostButton,
@ -430,19 +417,20 @@ input[type="checkbox"] { width: auto; margin: 0 8px 0 0; }
.toolRow {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(58px, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
align-items: center;
margin-bottom: 8px;
}
.editorActions {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.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;
@ -563,7 +551,7 @@ input[type="checkbox"] { width: auto; margin: 0 8px 0 0; }
.toolHud { top: auto; bottom: 122px; right: 10px; }
.infoHud { left: 10px; right: 10px; width: auto; }
.studioDrawer { width: calc(100vw - 18px); }
.toolRow { grid-template-columns: repeat(auto-fit, minmax(54px, 1fr)); }
.toolRow { grid-template-columns: 44px repeat(3, minmax(0, 1fr)); }
}
/* refinements */
@ -600,7 +588,7 @@ input[type="checkbox"] { width: auto; margin: 0 8px 0 0; }
max-height: min(42vh, 330px);
}
.toolRow {
grid-template-columns: repeat(auto-fit, minmax(58px, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
margin-top: 0;
}
.paletteGrid {
@ -721,21 +709,6 @@ body, button, input, select, textarea { font-smooth: never; -webkit-font-smoothi
/* Selection bubble + outline controls */
.palettePanel { display: grid; gap: 8px; }
.outlineControls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 5px;
align-items: center;
font-size: 10px;
}
.outlineControls span {
grid-column: 1 / -1;
display: block;
border: 2px solid rgba(36,48,68,.28);
background: #fffaf0;
padding: 5px 6px;
font-weight: 900;
}
.selectionBubble {
position: absolute;
left: 0;
@ -816,3 +789,229 @@ body, button, input, select, textarea { font-smooth: never; -webkit-font-smoothi
.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;
}
/* Phase 5: keep advanced depth controls inside the drawer. */
.advancedRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
max-width: 100%;
overflow: hidden;
}
.advancedRow .tool {
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
padding: 7px 7px;
white-space: nowrap;
}
#advancedHint {
flex: 1 0 100%;
min-width: 0;
}
#guardrailStats {
white-space: pre-wrap;
}
.bubbleActions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.bubbleActions button:only-child,
.bubbleActions button:nth-child(3):last-child {
grid-column: span 2;
}
/* Phase 5 follow-up: object hidden list + report dialog + font priority. */
body, button, input, select, textarea {
font-family: "3x5 MT Pixel Font", "Press Start 2P", "PixelMplus10", "DotGothic16", "MS Gothic", "Osaka-Mono", "Courier New", ui-monospace, monospace;
}
.hiddenSectionTitle {
margin: 8px 0 6px;
padding: 5px 7px;
background: #ffe6f0;
border: 2px solid var(--line);
font-size: 11px;
font-weight: 900;
text-transform: uppercase;
}
.reportDialog {
position: absolute;
z-index: 20;
inset: 0;
display: grid;
place-items: center;
background: rgba(20, 25, 34, .34);
}
.reportDialog[hidden] { display: none; }
.reportCard {
width: min(360px, calc(100vw - 28px));
background: #fffdf5;
border: 3px solid var(--line);
box-shadow: 8px 8px 0 rgba(36,48,68,.25);
padding: 14px;
}
.reportCard .hint { margin: 8px 0 10px; }
.reportActions button { min-width: 0; }
.assetActions button { min-width: 0; }
.toggleList { display:flex; flex-direction:column; gap:10px; }
.checkRow { display:flex; align-items:center; gap:10px; font-size:13px; color:#243044; }
.checkRow input { width:16px; height:16px; }
/* Phase 5 UI polish: larger readable controls while keeping palette code labels small. */
body, button, input, select, textarea { font-size: 15px; }
.logo { font-size: 24px; }
.subline { font-size: 14px; }
.authorCard span, .clockHint { font-size: 12px; }
.phaseLabel { font-size: 14px; }
.iconButton, .tool, .tab, button { font-size: 14px; line-height: 1.15; }
.toolHud .iconButton { font-size: 14px; min-width: 74px; padding: 10px 12px; }
.cardTitle { font-size: 17px; }
.field, .hint, .lineageNote, .syncStats { font-size: 14px; line-height: 1.45; }
.toolRow { gap: 8px; }
.editorHistoryRow { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.advancedRow { grid-template-columns: repeat(6, max-content) 1fr; align-items: start; }
.assetMeta strong { font-size: 16px; }
.assetMeta span { font-size: 13px; }
.assetActions button { font-size: 12px; padding: 6px 7px; }
.selectionBubble { font-size: 14px; }
.bubbleName { font-size: 15px; }
.bubbleAuthor, .bubbleRemixFrom, .bubbleRemixCount { font-size: 12px; }
.bubbleActions button, .bubbleVotes button { font-size: 12px; padding: 6px 8px; }
.paletteGrid button, .paletteSwatch { font-size: 7px !important; }
.checkRow { font-size: 14px; }
@media (max-width: 760px) {
body, button, input, select, textarea { font-size: 14px; }
.toolHud .iconButton { min-width: 68px; }
.editorHistoryRow { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.advancedRow { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
.displayLimitField input { font-size: 16px; min-height: 36px; width: 110px; }
.rotationBadge { font-size: 13px; color: #4d5b72; }
/* exhibition/account polish */
.authorCard { min-width: 210px; }
.authorCard small { display:block; margin-top:6px; font-size:12px; line-height:1.35; opacity:.82; }
.miniButton { margin-top:6px; padding:6px 8px; border:2px solid #243044; background:#fff3d9; box-shadow:2px 2px 0 rgba(36,48,68,.22); cursor:pointer; font-size:12px; }
.placementPreviewBar { position:fixed; left:50%; bottom:22px; transform:translateX(-50%); z-index:35; display:flex; align-items:center; gap:10px; max-width:min(760px, calc(100vw - 24px)); padding:12px 14px; border:3px solid #243044; background:#fff7de; box-shadow:5px 5px 0 rgba(36,48,68,.25); font-size:15px; }
.placementPreviewBar[hidden] { display:none; }
.likedCodex { margin:8px 0 14px; padding:10px; border:2px dashed rgba(36,48,68,.35); background:rgba(255,255,255,.45); font-size:14px; }
.likedCodexTitle { font-weight:700; margin-bottom:6px; }
.likedCodexList { display:flex; gap:8px; flex-wrap:wrap; }
.likedCodexItem { display:flex; align-items:center; gap:6px; border:2px solid rgba(36,48,68,.25); background:#fffdf5; padding:4px 6px; max-width:180px; }
.likedCodexItem canvas { width:28px; height:28px; image-rendering:pixelated; }
.hiddenReasonText { color:#6f2f3b; font-weight:700; }
.exhibitionNote { padding:8px 10px; background:#fff3d9; border:2px solid rgba(36,48,68,.2); margin-bottom:8px; font-size:14px; line-height:1.45; }

901
styles.css.bak Normal file
View file

@ -0,0 +1,901 @@
: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;
}