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
|
# 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
|
```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`.
|
## Exhibition Policy Revision
|
||||||
- The vote bubble is local only.
|
|
||||||
- Server-side moderation is not implemented yet; see the TODO comment in `app.js` near `voteSelected()`.
|
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
155
index.html
155
index.html
|
|
@ -11,6 +11,11 @@
|
||||||
<canvas id="worldCanvas" aria-label="Island map"></canvas>
|
<canvas id="worldCanvas" aria-label="Island map"></canvas>
|
||||||
|
|
||||||
<button id="openEditor" class="edgeAdd" title="Open Pixel Studio">+</button>
|
<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">
|
<header class="hud topHud">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -20,6 +25,8 @@
|
||||||
<label class="authorCard">
|
<label class="authorCard">
|
||||||
<span>Your Name</span>
|
<span>Your Name</span>
|
||||||
<input id="authorName" type="text" maxlength="24" value="Local Artist" />
|
<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>
|
</label>
|
||||||
<div class="clockCard" aria-label="world clock">
|
<div class="clockCard" aria-label="world clock">
|
||||||
<div id="phaseLabel" class="phaseLabel">Day</div>
|
<div id="phaseLabel" class="phaseLabel">Day</div>
|
||||||
|
|
@ -33,26 +40,18 @@
|
||||||
<button id="modePlace" class="iconButton" title="Summon selected asset">Place</button>
|
<button id="modePlace" class="iconButton" title="Summon selected asset">Place</button>
|
||||||
<button id="modeErase" class="iconButton danger" title="Remove asset">Erase</button>
|
<button id="modeErase" class="iconButton danger" title="Remove asset">Erase</button>
|
||||||
<div class="divider"></div>
|
<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="zoomIn" class="iconButton" title="Zoom in">+</button>
|
||||||
<button id="resetView" class="iconButton" title="Reset view">Reset</button>
|
<button id="resetView" class="iconButton" title="Reset view">Reset</button>
|
||||||
<button id="settingsToggle" class="iconButton" title="World settings">Settings</button>
|
|
||||||
</aside>
|
</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">
|
<section id="studioDrawer" class="studioDrawer" aria-label="Pixel Studio">
|
||||||
<div class="drawerHead">
|
<div class="drawerHead">
|
||||||
<div>
|
<div>
|
||||||
<h1>Pixel Studio</h1>
|
<h1>Pixel Studio</h1>
|
||||||
<p>Make static scenery or moving island life.</p>
|
<p>Make static scenery or moving island life.</p>
|
||||||
</div>
|
</div>
|
||||||
<button id="closeEditor" class="ghostButton" title="Close">x</button>
|
<button id="closeEditor" class="ghostButton" title="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="tabs" aria-label="Studio tabs">
|
<nav class="tabs" aria-label="Studio tabs">
|
||||||
|
|
@ -67,10 +66,10 @@
|
||||||
<div class="quickSetupBar twoCols">
|
<div class="quickSetupBar twoCols">
|
||||||
<label class="field">Canvas size
|
<label class="field">Canvas size
|
||||||
<select id="assetSize">
|
<select id="assetSize">
|
||||||
<option value="8" selected>8 x 8</option>
|
<option value="8" selected>8×8</option>
|
||||||
<option value="16">16 x 16</option>
|
<option value="16">16×16</option>
|
||||||
<option value="32">32 x 32</option>
|
<option value="32">32×32</option>
|
||||||
<option value="64">64 x 64</option>
|
<option value="64">64×64</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">Role
|
<label class="field">Role
|
||||||
|
|
@ -79,6 +78,7 @@
|
||||||
<option value="animal">Animal</option>
|
<option value="animal">Animal</option>
|
||||||
<option value="nature" selected>Nature</option>
|
<option value="nature" selected>Nature</option>
|
||||||
<option value="building">Building</option>
|
<option value="building">Building</option>
|
||||||
|
<option value="ship">Ship</option>
|
||||||
<option value="other">Other</option>
|
<option value="other">Other</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -89,8 +89,8 @@
|
||||||
</label>
|
</label>
|
||||||
<div class="editorTop compactEditorTop">
|
<div class="editorTop compactEditorTop">
|
||||||
<div id="sideSwitcher" class="sideSwitcher" hidden aria-label="Sprite direction">
|
<div id="sideSwitcher" class="sideSwitcher" hidden aria-label="Sprite direction">
|
||||||
<button id="editLeft" title="Left-facing sprite">Left</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>
|
<button id="editRight" class="active" title="Right-facing sprite is the main/front direction">▶ Right</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -98,37 +98,48 @@
|
||||||
<canvas id="paintCanvas" width="512" height="512" aria-label="Pixel editor"></canvas>
|
<canvas id="paintCanvas" width="512" height="512" aria-label="Pixel editor"></canvas>
|
||||||
<div class="palettePanel">
|
<div class="palettePanel">
|
||||||
<div id="paletteGrid" class="paletteGrid" aria-label="Color palette"></div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="toolRow">
|
<div class="toolRow editorToolRow">
|
||||||
<button id="toolBrush" class="tool active">Draw</button>
|
<button id="toolBrush" class="tool active" title="B">Draw</button>
|
||||||
<button id="toolErase" class="tool">Erase</button>
|
<button id="toolErase" class="tool" title="E">Erase</button>
|
||||||
<button id="toolFill" class="tool">Fill</button>
|
<button id="toolFill" class="tool" title="F">Fill</button>
|
||||||
<button id="toolPick" class="tool">Pick</button>
|
<button id="toolPick" class="tool" title="I">Pick</button>
|
||||||
<button id="toolLight" class="tool">Light</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="toolDoor" class="tool buildingOnly">Door</button>
|
||||||
<button id="clearPaint" class="tool danger">Clear</button>
|
<button id="clearPaint" class="tool danger">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolRow editorActions">
|
<div class="toolRow editorHistoryRow">
|
||||||
<button id="undoPaint" class="tool" type="button">Undo</button>
|
<button id="undoPaint" class="tool" type="button" disabled>Undo</button>
|
||||||
<button id="redoPaint" class="tool" type="button">Redo</button>
|
<button id="redoPaint" class="tool" type="button" disabled>Redo</button>
|
||||||
<button id="mirrorLeft" class="tool" type="button">Mirror Left</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>
|
</div>
|
||||||
<input id="paintColor" type="color" value="#6bd06b" hidden />
|
<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>
|
||||||
|
|
||||||
<div class="card stack summonCard">
|
<div class="card stack summonCard">
|
||||||
<div class="cardTitle">Finish</div>
|
<div class="cardTitle">Finish</div>
|
||||||
<div class="actionRow finishActions">
|
<div class="actionRow finishActions">
|
||||||
<button id="saveAndPlace" class="primary bigPrimary">Save + Summon on Map</button>
|
<button id="saveAndPlace" class="primary bigPrimary">Save + Place on Island</button>
|
||||||
<button id="saveAsset" class="secondary">Save only</button>
|
<button id="saveAsset" class="secondary">Save to Library</button>
|
||||||
|
<button id="checkOnIsland" class="secondary">Check on Island</button>
|
||||||
<button id="newAsset" class="secondary">New</button>
|
<button id="newAsset" class="secondary">New</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -137,8 +148,16 @@
|
||||||
|
|
||||||
<section id="tab-library" class="tabPanel">
|
<section id="tab-library" class="tabPanel">
|
||||||
<div class="card stack">
|
<div class="card stack">
|
||||||
|
<div class="libraryHead">
|
||||||
<div class="cardTitle">Library</div>
|
<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>
|
<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 id="assetList" class="assetList"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -146,14 +165,47 @@
|
||||||
<section id="tab-data" class="tabPanel">
|
<section id="tab-data" class="tabPanel">
|
||||||
<div class="card stack">
|
<div class="card stack">
|
||||||
<div class="cardTitle">Local save</div>
|
<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">
|
<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="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>
|
<button id="resetAll" class="danger">Reset all</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="syncStats" class="syncStats"></div>
|
||||||
<textarea id="dataBox" rows="10" placeholder="Exported JSON appears here."></textarea>
|
<textarea id="dataBox" rows="10" placeholder="Exported JSON appears here."></textarea>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -164,22 +216,41 @@
|
||||||
<div id="bubbleRemixFrom" class="bubbleRemixFrom" hidden></div>
|
<div id="bubbleRemixFrom" class="bubbleRemixFrom" hidden></div>
|
||||||
<div id="bubbleRemixCount" class="bubbleRemixCount">Remixed: 0</div>
|
<div id="bubbleRemixCount" class="bubbleRemixCount">Remixed: 0</div>
|
||||||
<div class="bubbleVotes">
|
<div class="bubbleVotes">
|
||||||
<button id="voteUp" type="button">Up</button>
|
<button id="voteUp" type="button">▲</button>
|
||||||
<span id="voteScore">0</span>
|
<span id="voteScore">0</span>
|
||||||
<button id="voteDown" type="button">Down</button>
|
<button id="voteDown" type="button">▼</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="bubbleActions">
|
<div class="bubbleActions">
|
||||||
<button id="bubbleRemix" type="button">Remix</button>
|
<button id="bubbleRemix" type="button">Remix</button>
|
||||||
|
<button id="bubbleReport" type="button">Report</button>
|
||||||
<button id="bubbleHide" type="button" hidden>Hide</button>
|
<button id="bubbleHide" type="button" hidden>Hide</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="toast" class="toast" hidden></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>
|
</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/editor-actions.js"></script>
|
||||||
|
<script src="./js/phase2-sync.js"></script>
|
||||||
<script src="./app.js"></script>
|
<script src="./app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 ||= {};
|
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 = {
|
root.EditorActions = {
|
||||||
|
clonePixels,
|
||||||
|
floodFill,
|
||||||
|
pickColor,
|
||||||
apply(snapshot, pushHistory, mutate) {
|
apply(snapshot, pushHistory, mutate) {
|
||||||
pushHistory(snapshot());
|
pushHistory(snapshot());
|
||||||
mutate();
|
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;
|
margin: 0;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: #92d8ff;
|
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;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
button, input, select, textarea { font: inherit; }
|
button, input, select, textarea { font: inherit; }
|
||||||
button { -webkit-tap-highlight-color: transparent; }
|
button { -webkit-tap-highlight-color: transparent; }
|
||||||
button:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: .48;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -183,7 +178,7 @@ button:disabled {
|
||||||
.edgeAdd:hover { background: var(--pink-dark); }
|
.edgeAdd:hover { background: var(--pink-dark); }
|
||||||
.edgeAdd:active { transform: translateY(calc(-50% + 2px)); box-shadow: 2px 2px 0 rgba(34,45,63,.22); }
|
.edgeAdd:active { transform: translateY(calc(-50% + 2px)); box-shadow: 2px 2px 0 rgba(34,45,63,.22); }
|
||||||
|
|
||||||
.toolHud {
|
.toolHud {
|
||||||
right: 14px;
|
right: 14px;
|
||||||
top: 116px;
|
top: 116px;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -192,14 +187,6 @@ button:disabled {
|
||||||
padding: 9px;
|
padding: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settingsPanel {
|
|
||||||
top: 142px;
|
|
||||||
right: 126px;
|
|
||||||
z-index: 6;
|
|
||||||
padding: 10px 12px;
|
|
||||||
min-width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconButton,
|
.iconButton,
|
||||||
button,
|
button,
|
||||||
.ghostButton,
|
.ghostButton,
|
||||||
|
|
@ -430,19 +417,20 @@ input[type="checkbox"] { width: auto; margin: 0 8px 0 0; }
|
||||||
|
|
||||||
.toolRow {
|
.toolRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(58px, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 8px;
|
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; }
|
.toolRow input { margin: 0; }
|
||||||
.tool { padding: 7px 5px; font-size: 11px; }
|
.tool { padding: 7px 5px; font-size: 11px; }
|
||||||
|
button:disabled, .tool:disabled { opacity: .45; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||||
|
|
||||||
#paintCanvas {
|
#paintCanvas {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
@ -563,7 +551,7 @@ input[type="checkbox"] { width: auto; margin: 0 8px 0 0; }
|
||||||
.toolHud { top: auto; bottom: 122px; right: 10px; }
|
.toolHud { top: auto; bottom: 122px; right: 10px; }
|
||||||
.infoHud { left: 10px; right: 10px; width: auto; }
|
.infoHud { left: 10px; right: 10px; width: auto; }
|
||||||
.studioDrawer { width: calc(100vw - 18px); }
|
.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 */
|
/* refinements */
|
||||||
|
|
@ -600,7 +588,7 @@ input[type="checkbox"] { width: auto; margin: 0 8px 0 0; }
|
||||||
max-height: min(42vh, 330px);
|
max-height: min(42vh, 330px);
|
||||||
}
|
}
|
||||||
.toolRow {
|
.toolRow {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(58px, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
.paletteGrid {
|
.paletteGrid {
|
||||||
|
|
@ -721,21 +709,6 @@ body, button, input, select, textarea { font-smooth: never; -webkit-font-smoothi
|
||||||
|
|
||||||
/* Selection bubble + outline controls */
|
/* Selection bubble + outline controls */
|
||||||
.palettePanel { display: grid; gap: 8px; }
|
.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 {
|
.selectionBubble {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
@ -816,3 +789,229 @@ body, button, input, select, textarea { font-smooth: never; -webkit-font-smoothi
|
||||||
.bubbleVotes button.activeVote {
|
.bubbleVotes button.activeVote {
|
||||||
background: var(--sun);
|
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