Compare commits
4 commits
2d42f7f487
...
67a7477242
| Author | SHA1 | Date | |
|---|---|---|---|
| 67a7477242 | |||
| 2501a4c975 | |||
| 5b5848f1dc | |||
| 89a32dc28e |
15 changed files with 9715 additions and 1000 deletions
194
README.md
194
README.md
|
|
@ -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
58
README.md.bak
Normal 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.
|
||||
3774
app.js.bak
Normal file
3774
app.js.bak
Normal file
File diff suppressed because it is too large
Load diff
157
index.html
157
index.html
|
|
@ -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
203
index.html.bak
Normal 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>
|
||||
|
|
@ -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
429
js/phase2-sync.js
Normal 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
402
js/phase2-sync.js.bak
Normal 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
|
||||
};
|
||||
})();
|
||||
BIN
server/__pycache__/compression_lab.cpython-313.pyc
Normal file
BIN
server/__pycache__/compression_lab.cpython-313.pyc
Normal file
Binary file not shown.
BIN
server/__pycache__/rotation_worker.cpython-313.pyc
Normal file
BIN
server/__pycache__/rotation_worker.cpython-313.pyc
Normal file
Binary file not shown.
42
server/compression_lab.py
Normal file
42
server/compression_lab.py
Normal 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
316
server/rotation_worker.py
Normal 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())
|
||||
273
styles.css
273
styles.css
|
|
@ -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
901
styles.css.bak
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue