Compare commits
6 commits
67a7477242
...
06ab438d7b
| Author | SHA1 | Date | |
|---|---|---|---|
| 06ab438d7b | |||
| 43b659b64b | |||
| 06176fb2d5 | |||
| 8ee6a87d44 | |||
| 94571943cf | |||
| a05c031074 |
26 changed files with 9860 additions and 6706 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
*.bak
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
166
README.md
166
README.md
|
|
@ -6,13 +6,21 @@ Browser-only prototype for a shared pixel-art island.
|
|||
|
||||
Open `index.html` in a modern browser. No build step or third-party dependency is required.
|
||||
|
||||
## Refactor layout
|
||||
|
||||
- `app.js` remains the browser bootstrap and main UI coordinator.
|
||||
- `js/core-utils.js`, `js/state-index.js`, `js/rotation-policy.js`, and `js/lighting.js` hold reusable logic shared by the app.
|
||||
- `js/editor-actions.js` is lazy-loaded when Pixel Studio opens; the app keeps inline fallbacks for first-use safety.
|
||||
- Server rotation defaults can be supplied with `server/rotation_policy.json` and `--config`.
|
||||
- Server policy tests live in `server/test_rotation_worker.py`.
|
||||
|
||||
## Current prototype scope
|
||||
|
||||
- Draw pixel assets in the in-browser editor.
|
||||
- Save assets to the local library.
|
||||
- Save assets to the local collection.
|
||||
- Place static assets and summon dynamic assets onto the island.
|
||||
- Dynamic assets default to right-facing on spawn; left-facing is rendered by mirroring during movement only.
|
||||
- Local voting, hiding, remix/copy edit, and import/export tools are included.
|
||||
- Local voting, hiding, remix/copy edit, and collection management are included.
|
||||
|
||||
## Phase 1 changes
|
||||
|
||||
|
|
@ -25,7 +33,6 @@ Open `index.html` in a modern browser. No build step or third-party dependency i
|
|||
|
||||
- Local save uses a compact Phase 2 envelope in `localStorage`.
|
||||
- Pixel planes are bbox-cropped and RLE-compressed when this is smaller than raw cropped data.
|
||||
- Compact asset bundles can be exported separately from world snapshots.
|
||||
- World snapshots contain placements, dynamic summons, and an asset manifest rather than full asset blobs.
|
||||
- Local object/asset mutations append small sync events to `eventLog`.
|
||||
- IndexedDB asset/snapshot cache helpers are included for future server-backed missing-asset fetches.
|
||||
|
|
@ -38,7 +45,6 @@ Open `index.html` in a modern browser. No build step or third-party dependency i
|
|||
- Dynamic runtime updates are stepped at 15 Hz and capped per frame to avoid spiral-of-death on slow devices.
|
||||
- Rendering pauses while the browser tab is hidden and resumes on visibility change.
|
||||
- Tile/object lookups use a small spatial index for inspect, erase, and nearby-target searches.
|
||||
- Data tab reports visible terrain chunks for quick performance inspection.
|
||||
|
||||
## Phase 4 changes
|
||||
|
||||
|
|
@ -47,20 +53,11 @@ Open `index.html` in a modern browser. No build step or third-party dependency i
|
|||
- Added Select tool. Drag to select a rectangular area, then drag the selection or use arrow keys/buttons to move it.
|
||||
- Nudge buttons move the active selection; without a selection they shift the whole drawing.
|
||||
- Added horizontal/vertical flip, palette-colored outline generation, and clear selection.
|
||||
- Added editor PNG export/import. PNG import quantizes the image to the current island palette and current canvas size.
|
||||
- Added keyboard shortcuts: B/E/F/I/L/R/S for tools, Ctrl/Cmd+Z/Y for history, arrows for moving selected pixels, Escape to clear selection.
|
||||
|
||||
## Export formats
|
||||
|
||||
- **Export full JSON**: human-readable full local state.
|
||||
- **Export compact**: compact local state suitable for saving/transferring.
|
||||
- **Export snapshot**: placement state plus asset manifest; requires asset bundles or cache to render fully.
|
||||
- **Export asset bundle**: compact asset blob list for missing-asset transfer.
|
||||
|
||||
|
||||
## Phase 4 follow-up adjustments
|
||||
|
||||
- PNG import now chooses the editor canvas size from the image size. Square 8/16/32/64 PNGs import at their native size; other sizes are fit into the nearest supported square canvas.
|
||||
- Depth is now three-state: high, normal, and low. High brightens sprite pixels; low darkens them.
|
||||
- Right-clicking the world canvas clears the current map selection instead of moving or placing the selected asset.
|
||||
- Dynamic sprites now pause intermittently instead of walking continuously.
|
||||
|
|
@ -72,16 +69,7 @@ Open `index.html` in a modern browser. No build step or third-party dependency i
|
|||
|
||||
- Editor schema is now 7.
|
||||
- Fixed the Advanced Draw depth controls so `Depth / High / Low / Clear` wrap inside the drawer instead of overflowing to the right.
|
||||
- Added Phase 5 guardrails in the Data tab:
|
||||
- local asset/object volume counters,
|
||||
- duplicate-content count,
|
||||
- orphan placement checks,
|
||||
- terrain compatibility checks,
|
||||
- hidden/report counters.
|
||||
- Added `Validate world` export for pre-server integrity checks.
|
||||
- Added local object reporting from the selection bubble. Reported objects are hidden locally and stored in a local moderation report log.
|
||||
- Added `Export moderation report` for review/debug data.
|
||||
- Added `Clear reports` to clear local report logs without un-hiding already hidden objects.
|
||||
- Added soft local guardrail limits: 160 assets and 220 world objects. Existing objects can still be moved; new saves/placements are blocked when the local cap is reached.
|
||||
|
||||
## Phase 5 follow-up changes
|
||||
|
|
@ -121,7 +109,6 @@ Open `index.html` in a modern browser. No build step or third-party dependency i
|
|||
- Walking particles are less frequent and use more visibly varied colors.
|
||||
- Remix always creates a new derivative asset; Edit updates the user's own original asset.
|
||||
- Advanced Draw now includes configurable particle emitter cells for any asset.
|
||||
- PNG export/import moved to the Data tab.
|
||||
- Nudge arrow buttons under the canvas were removed; keyboard arrow nudging remains.
|
||||
- UI text is larger overall, while palette color-code labels keep their small size.
|
||||
|
||||
|
|
@ -130,7 +117,7 @@ Open `index.html` in a modern browser. No build step or third-party dependency i
|
|||
This build adds count-based island rotation.
|
||||
|
||||
- Default island display cap: 250 objects.
|
||||
- The local display cap is adjustable from Data > Visual settings.
|
||||
- The local display cap is adjustable from Settings.
|
||||
- Objects are not deleted when the cap is exceeded; the effective oldest objects are omitted from the island display.
|
||||
- Upvotes delay rotation-out by slot adjustment. Downvotes advance rotation-out.
|
||||
- Local publish / republish quota: 5 objects per author per rolling hour.
|
||||
|
|
@ -161,12 +148,12 @@ This build does not call a server from the browser. The island display cap is ap
|
|||
This build separates **Asset** and **PlacementObject** more explicitly.
|
||||
|
||||
- Asset: a durable library work. Remix only copies pixels and lineage into the canvas.
|
||||
- PlacementObject: a temporary island exhibition object. It can rotate out while the Asset remains in Library.
|
||||
- PlacementObject: a temporary island exhibition object. It can rotate out while the Asset remains in Collection.
|
||||
- Edit has been removed. To revise a work, remix/copy it, save a new Asset, then delete the old one if desired.
|
||||
- Publishing requires a prototype local account. The first 24 hours allow 5 public placements/hour; day 2+ allows 10/hour.
|
||||
- The island exhibition has 250 visible slots: 150 newest slots and 100 random revival slots.
|
||||
- Upvote rank effect is capped at +50 votes.
|
||||
- Capacity rotation and extreme downvote rotation use the same user-facing explanation: the island exhibition is full, but the work remains in Library.
|
||||
- Capacity rotation and extreme downvote rotation use the same user-facing explanation: the island exhibition is full, but the work remains in Collection.
|
||||
- Server moderation can mark `permanent_hidden` / `violation_hidden`; these are hidden from authors and viewers. Admins can restore them with the Python worker.
|
||||
- Particles are disabled offscreen/when zoomed out and capped at 200 active particles.
|
||||
|
||||
|
|
@ -187,3 +174,130 @@ python server/compression_lab.py exported_world.json
|
|||
```
|
||||
|
||||
Use this to compare minified JSON, gzip, zlib, and Brotli when available. Production should use the compact asset codec plus HTTP gzip/Brotli rather than hand-rolled custom compression first.
|
||||
|
||||
## Phase 5 Exhibition UI polish follow-up
|
||||
- Compressed the account/name card so the top HUD uses less vertical space.
|
||||
- Removed the right-side Pan / Place / Erase HUD; placement is guided through Save + Place and Library actions.
|
||||
- Renamed the liked codex label to Favorite.
|
||||
- Removed the Advanced Draw depth Clear button; depth can still be cleared with Shift/right-click.
|
||||
- Normal Erase now removes Light / Particle / Depth metadata on the first click and removes the pixel color on the next click.
|
||||
- Light rendering and nearby glow are night-only, with subtle random flicker.
|
||||
- Remix previews hide the nearby source work while checking placement, and final placement nudges away from the remix source to avoid doubled sprites.
|
||||
- Depth edge shading now reacts to configured light positions or the current sun/moon direction; object silhouette edges are excluded.
|
||||
|
||||
## v8 polish notes
|
||||
|
||||
- `Save + Place on Island` now auto-generates a local account when needed. The generated account stores an ID, editable display name, and generated password in local storage; users are prompted to change the password.
|
||||
- `Check on Island` and `New` were removed from the finish area. Saved works enter an island placement preview with `Place here` and `Back to canvas`.
|
||||
- Editor Select now clears with right-click. The island also shows the left-click/right-click selection hint.
|
||||
- Basic draw buttons keep a stable layout even when Advanced is open; advanced tools are placed below them.
|
||||
- Draw tools now have distinct colors and compact icons.
|
||||
- Particle editing no longer paints emitter positions. Particle effects emit from the whole sprite, with palette-based color and up/down/left/right direction.
|
||||
|
||||
|
||||
## v9 interaction polish and bug fixes
|
||||
|
||||
- Removed automatic remix-source avoidance: remix children can now be placed near their source without being forced to a different tile.
|
||||
- Animated coastal foam with drift, bob, scale, and opacity pulses.
|
||||
- Removed Collection/Create buttons from the Pixel Island title bar and added a compact cute menu button for the left drawer.
|
||||
- Added a confirmation dialog before clearing the entire canvas.
|
||||
- Palette swatches now handle pointerdown directly so the first palette click is applied reliably.
|
||||
- Particle right-click no longer disables the particle effect; right-click pans/does nothing, while Shift-click can disable.
|
||||
- Added particle range controls: select an area, then press “Use Sel”; “Whole” resets emission to the whole sprite.
|
||||
- Swapped Particle and Depth button order.
|
||||
- Left/Right canvas side buttons no longer mirror the editing canvas view.
|
||||
- Fixed dynamic animal placement preview so the sprite is visible before pressing Place here.
|
||||
|
||||
### v10 interaction / lighting polish
|
||||
|
||||
- Replaced the title-side menu control with a larger bottom `DRAW` button.
|
||||
- Warmed the evening phase with orange/vermilion sky tint and warm darkness overlay.
|
||||
- Increased lamp reflection bleed radius and light radius so nearby sprites glow more softly.
|
||||
- Dynamic sprites now save a canvas marked `Left` by mirroring it into the canonical right-facing asset; runtime facing is driven by the actual movement vector.
|
||||
- Hidden particle range overlays no longer look like an undeletable blue canvas square; the range overlay is shown only while the Particle tool is active.
|
||||
- Remix actions are now displayed as a bottom sheet. Report is inside a small Menu section.
|
||||
- Added teleport-to-remix-source from the selected object sheet.
|
||||
- Object draw order now uses the rendered bottom/contact line, so lower objects consistently draw in front.
|
||||
- 8×8 sprites use their whole sprite bounding box for click selection, including transparent cells.
|
||||
- Coastal foam motion was made more visible.
|
||||
|
||||
|
||||
## v12 lighting/compression notes
|
||||
|
||||
- Night light reflections are now rendered per sprite immediately after the sprite, preserving front/back draw order.
|
||||
- Reflection tint affects both depth and non-depth pixels; depth pixels receive stronger directional response.
|
||||
- Depth no longer uses light-source response unless the night lighting system is actually active.
|
||||
- Strong compression direction for weak servers: store indexed palette pixels as bit-packed palette indices plus an alpha bitmask and RLE/LZ on top; avoid PNG/base64 as the canonical server payload.
|
||||
|
||||
## v13 compression and remix lineage changes
|
||||
|
||||
- Palette index encoding is the canonical pixel format. Each pixel stores `.` for transparent or a one-character palette code.
|
||||
- Compact export now supports `bp6`, a 6-bit packed plane format. Transparent plus the 62 palette codes fit in one 6-bit value.
|
||||
- Compact export chooses the smallest representation per pixel plane: cropped raw, cropped RLE, or cropped 6-bit packing.
|
||||
- Remix delta storage was removed again; remixes are stored as independent packed pixel planes.
|
||||
- Deleting a source asset now cascades through remix descendants. Their island placements, dynamic summons, votes, hidden flags, and moderation rows are removed together.
|
||||
- Sync events now include `asset.delete`; applying that event also cascades to remix descendants on the receiver.
|
||||
|
||||
|
||||
## v14 rollback / interaction polish
|
||||
- Removed remix delta saving and remix-source cascade deletion.
|
||||
- Particles are cell-based again, like Light and Depth: choose color + direction, then paint emitter cells.
|
||||
- Selected dynamic sprites bounce too, with a slight deterministic tilt.
|
||||
- The object speech bubble is kept above the object and away from the DRAW dock.
|
||||
- Added a compact clock in the upper-right corner.
|
||||
- Reworked daylight/moonlight shadow transitions so evening fades sun shadows out before night, moon shadows fade in during night, and pre-dawn moon shadows disappear before sunrise.
|
||||
|
||||
## v14b startup fix
|
||||
- Restored `updateSelectedLabel()` so bootstrap no longer throws `ReferenceError`.
|
||||
|
||||
|
||||
## v15 interaction polish
|
||||
- Restored object speech bubbles above the actual rendered sprite, using sprite draw coordinates instead of tile-center estimates.
|
||||
- Replaced the numeric clock with a fixed analog day-night clock using a sky-color conic gradient and no numbers.
|
||||
- Kept selected-object shadows mirrored from the ground line while the sprite jumps.
|
||||
- Smoothed sky and overlay interpolation across dawn, day, evening, and night.
|
||||
|
||||
## v16b hotfix
|
||||
- Fixed the dark/blank world regression caused by using `source-in` compositing directly on the main world canvas for ship water reflection.
|
||||
- Ship reflections are now precomposited on an offscreen canvas before drawing, so terrain and sprites are not erased or darkened.
|
||||
|
||||
|
||||
## Phase 6 create-layout / authority prep
|
||||
|
||||
- The Create tab editor now follows the tighter canvas-first layout: top metadata row, canvas with a vertical palette, large tool buttons, and a full-width Advanced settings panel.
|
||||
- The palette ramps were rebuilt into cleaner 4-color rows so hue/lightness progression is easier to read.
|
||||
- Local browser data uses a new storage key so older palette-corrupted art is discarded automatically.
|
||||
- Shared-world prep now treats publish, object move, day/night time, and spontaneous dynamic motion as server-authoritative concerns.
|
||||
- The client can keep pending shared publish/move visuals locally while waiting for server validation, and dynamic runtime motion can interpolate toward server-supplied target positions.
|
||||
|
||||
## Phase 6b server-authority / Create tab correction
|
||||
|
||||
- CREATE layout now groups the canvas and primary tools in the left column with the palette as the right column, matching the attached design more closely.
|
||||
- Save schema was bumped to 15. Existing local art data from older palette mappings is intentionally reset and replaced with new seed artwork.
|
||||
- Added server-side `world.phase` and `dynamic.move` event helpers/tests. Shared clients should treat these events as the source of truth for day/night and autonomous dynamic positions.
|
||||
- Pending shared object moves now render as temporary local interpolation only; the persistent world state is updated only by server-issued events.
|
||||
- Depth shading can now use a cursor-local light source on rendered sprites at night.
|
||||
|
||||
## Custom update 3: colored local light fix + heritage/masterpiece gallery
|
||||
|
||||
- Save schema is now `25`.
|
||||
- Fixed the local depth-light wash so the affected area no longer collapses into a mostly gray band; local lights now preserve more of the source color.
|
||||
- Reworked depth local-light behavior so the source itself and nearby outer/depth corners catch the strongest highlights, while distance has a weaker effect on brightness than before.
|
||||
- Reduced the selection bubble offset so the bubble sits noticeably closer to the top of the selected artwork.
|
||||
- Added several new large default works themed around masterpieces, heritage, and artifacts: **Smile Portrait**, **Great Wave Panel**, **Sunflower Still Life**, **Rosetta Stela**, **Pharaoh Mask**, **Stone Circle**, **Terracotta Sentinel**.
|
||||
|
||||
## Custom update 4: cursor-light smoothing + gray-bloom hotfix
|
||||
|
||||
- Save schema is now `26`.
|
||||
- Fixed the urgent regression where a single local light could wash large parts of a sprite into a gray, low-chroma bloom.
|
||||
- Reduced local-light body fill on depth sprites so highlights stay concentrated on lit edges / corners instead of flattening the whole silhouette.
|
||||
- Smoothed cursor-light falloff so in-range / out-of-range transitions are less abrupt.
|
||||
- Removed the always-on center fallback for the cursor inspection light and softened the visible cursor halo, which eliminates the unwanted blue circular artifact when no pointer light should be shown.
|
||||
|
||||
## Custom update 5: asset-light gray fix + temporary global delete + more default works
|
||||
|
||||
- Save schema is now `27`.
|
||||
- Adjusted **asset-mounted local lights** again so they no longer wash large depth sprites into a gray body fill; their effect is concentrated much more strongly on lit edges and corners.
|
||||
- Fixed the analog clock hand offset (it had been visually about 5 degrees early).
|
||||
- Added a **temporary** delete override so **all collection works can be deleted** regardless of owner. This is clearly marked in `app.js` with comments and can be reverted by setting `TEMP_ALLOW_DELETE_ALL_WORKS = false`.
|
||||
- Added many more default works: **Rain Bell Tower**, **Crimson Pagoda**, **Meteor Forge**, **Ink Garden Screen**, **Meadow Totem**, **Twilight Caravan**, **Pepper Fox**, **Glass Manta**, **Festival Drummer**, **Bloom Sprite**.
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
# Pixel Island Summoner
|
||||
|
||||
Browser-only prototype for a shared pixel-art island.
|
||||
|
||||
## Run
|
||||
|
||||
Open `index.html` in a modern browser. No build step or third-party dependency is required.
|
||||
|
||||
## Current prototype scope
|
||||
|
||||
- Draw pixel assets in the in-browser editor.
|
||||
- Save assets to the local library.
|
||||
- Place static assets and summon dynamic assets onto the island.
|
||||
- Dynamic assets default to right-facing on spawn; left-facing is rendered by mirroring during movement only.
|
||||
- Local voting, hiding, remix/copy edit, and import/export tools are included.
|
||||
|
||||
## Phase 1 changes
|
||||
|
||||
- Asset data is normalized to schema 2+ compatible shape.
|
||||
- Dynamic left-facing pixels are not stored; `faces.left` is represented as `mirror`.
|
||||
- Empty depth/light metadata is omitted or normalized.
|
||||
- Editor tools include Draw, Erase, Fill, Pick, Undo, and Redo.
|
||||
|
||||
## Phase 2 changes
|
||||
|
||||
- Local save uses a compact Phase 2 envelope in `localStorage`.
|
||||
- Pixel planes are bbox-cropped and RLE-compressed when this is smaller than raw cropped data.
|
||||
- Compact asset bundles can be exported separately from world snapshots.
|
||||
- World snapshots contain placements, dynamic summons, and an asset manifest rather than full asset blobs.
|
||||
- Local object/asset mutations append small sync events to `eventLog`.
|
||||
- IndexedDB asset/snapshot cache helpers are included for future server-backed missing-asset fetches.
|
||||
|
||||
## Phase 3 changes
|
||||
|
||||
- Terrain rendering is chunk-cached instead of using one large terrain canvas.
|
||||
- Only terrain chunks intersecting the current viewport are drawn.
|
||||
- Static and dynamic objects outside the viewport margin are culled before sprite generation/sorting.
|
||||
- Dynamic runtime updates are stepped at 15 Hz and capped per frame to avoid spiral-of-death on slow devices.
|
||||
- Rendering pauses while the browser tab is hidden and resumes on visibility change.
|
||||
- Tile/object lookups use a small spatial index for inspect, erase, and nearby-target searches.
|
||||
- Data tab reports visible terrain chunks for quick performance inspection.
|
||||
|
||||
## Phase 4 changes
|
||||
|
||||
- Editor schema is now 5.
|
||||
- Added Line and Rect tools. Rect supports filled rectangles with Shift; right-click erases line/rect cells.
|
||||
- Added Select tool. Drag to select a rectangular area, then drag the selection or use arrow keys/buttons to move it.
|
||||
- Nudge buttons move the active selection; without a selection they shift the whole drawing.
|
||||
- Added horizontal/vertical flip, palette-colored outline generation, and clear selection.
|
||||
- Added editor PNG export/import. PNG import quantizes the image to the current island palette and current canvas size.
|
||||
- Added keyboard shortcuts: B/E/F/I/L/R/S for tools, Ctrl/Cmd+Z/Y for history, arrows for moving selected pixels, Escape to clear selection.
|
||||
|
||||
## Export formats
|
||||
|
||||
- **Export full JSON**: human-readable full local state.
|
||||
- **Export compact**: compact local state suitable for saving/transferring.
|
||||
- **Export snapshot**: placement state plus asset manifest; requires asset bundles or cache to render fully.
|
||||
- **Export asset bundle**: compact asset blob list for missing-asset transfer.
|
||||
3774
app.js.bak
3774
app.js.bak
File diff suppressed because it is too large
Load diff
256
index.html
256
index.html
|
|
@ -9,41 +9,42 @@
|
|||
<body>
|
||||
<div class="app">
|
||||
<canvas id="worldCanvas" aria-label="Island map"></canvas>
|
||||
|
||||
<button id="openEditor" class="edgeAdd" title="Open Pixel Studio">+</button>
|
||||
<button id="openEditor" class="drawDockButton" type="button" title="Open Pixel Studio"><span>✎ DRAW</span><span id="drawQuotaBadge" class="quotaBadge drawQuotaBadge" aria-live="polite"></span></button>
|
||||
<div id="placementPreviewBar" class="placementPreviewBar" hidden>
|
||||
<button id="confirmPreviewPlace" class="primary" type="button">Place here</button>
|
||||
<button id="backToCanvas" class="secondary" type="button">Back to canvas</button>
|
||||
<span>Check how this work looks on the island. Works are permanent in Library; island placement is temporary exhibition.</span>
|
||||
<span>Check how this work looks on the island. Works are permanent in Collection; island placement is temporary exhibition.</span>
|
||||
</div>
|
||||
|
||||
<header class="hud topHud">
|
||||
<div>
|
||||
<header class="hud topHud islandTopBar">
|
||||
<div class="brandBlock">
|
||||
<div class="logo">Pixel Island</div>
|
||||
<div class="subline">Local prototype / Draw pixels, summon them to islands.</div>
|
||||
<div class="subline">A rotating island exhibition for tiny pixel works.</div>
|
||||
</div>
|
||||
<label class="authorCard">
|
||||
<span>Your Name</span>
|
||||
<input id="authorName" type="text" maxlength="24" value="Local Artist" />
|
||||
<button id="createAccount" class="miniButton" type="button">Create local account</button>
|
||||
<small id="accountNote">Account required to publish. Prototype only: name + local timestamp.</small>
|
||||
</label>
|
||||
<div class="clockCard" aria-label="world clock">
|
||||
<div id="phaseLabel" class="phaseLabel">Day</div>
|
||||
<div class="phaseBar"><span id="phaseBar"></span></div>
|
||||
<div class="clockHint">10 min = 1 day</div>
|
||||
<div class="authorCard accountCard utilityControl" aria-label="Local account">
|
||||
<div class="accountFields">
|
||||
<label class="accountField">ID
|
||||
<input id="accountId" type="text" readonly placeholder="auto" />
|
||||
</label>
|
||||
<label class="accountField">Name
|
||||
<input id="authorName" type="text" maxlength="24" value="Local Artist" />
|
||||
</label>
|
||||
<label class="accountField">Pass
|
||||
<input id="accountPass" type="text" maxlength="32" placeholder="auto" />
|
||||
</label>
|
||||
</div>
|
||||
<button id="createAccount" class="miniButton" type="button">Generate account</button>
|
||||
<small id="accountNote">Publish creates a local account for island publishing.</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside class="hud toolHud">
|
||||
<button id="modeInspect" class="iconButton active" title="Pan / Inspect">Pan</button>
|
||||
<button id="modePlace" class="iconButton" title="Summon selected asset">Place</button>
|
||||
<button id="modeErase" class="iconButton danger" title="Remove asset">Erase</button>
|
||||
<div class="divider"></div>
|
||||
<button id="zoomOut" class="iconButton" title="Zoom out">−</button>
|
||||
<button id="zoomIn" class="iconButton" title="Zoom in">+</button>
|
||||
<button id="resetView" class="iconButton" title="Reset view">Reset</button>
|
||||
</aside>
|
||||
<div id="analogClock" class="analogClock" aria-label="Island day-night clock" title="10 min = 1 island day">
|
||||
<div class="analogClockDial">
|
||||
<span id="analogClockHand" class="analogClockHand"></span>
|
||||
</div>
|
||||
<div id="phaseLabel" class="phaseLabel analogClockLabel">Day</div>
|
||||
<div class="phaseBar analogPhaseBar" aria-hidden="true"><span id="phaseBar"></span></div>
|
||||
</div>
|
||||
|
||||
<section id="studioDrawer" class="studioDrawer" aria-label="Pixel Studio">
|
||||
<div class="drawerHead">
|
||||
|
|
@ -55,38 +56,31 @@
|
|||
</div>
|
||||
|
||||
<nav class="tabs" aria-label="Studio tabs">
|
||||
<button class="tab active" data-tab="draw">Draw</button>
|
||||
<button class="tab" data-tab="library">Library</button>
|
||||
<button class="tab" data-tab="data">Data</button>
|
||||
<button class="tab active" data-tab="draw">Create</button>
|
||||
<button class="tab" data-tab="library">Collection</button>
|
||||
<button class="tab" data-tab="settings">Settings</button>
|
||||
</nav>
|
||||
|
||||
<div class="drawerBody">
|
||||
<section id="tab-draw" class="tabPanel active">
|
||||
<div class="card editorCard heroEditor">
|
||||
<div class="quickSetupBar twoCols">
|
||||
<label class="field">Canvas size
|
||||
<select id="assetSize">
|
||||
<option value="8" selected>8×8</option>
|
||||
<option value="16">16×16</option>
|
||||
<option value="32">32×32</option>
|
||||
<option value="64">64×64</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">Role
|
||||
<select id="assetCategory">
|
||||
<option value="human">Human</option>
|
||||
<option value="animal">Animal</option>
|
||||
<option value="nature" selected>Nature</option>
|
||||
<option value="building">Building</option>
|
||||
<option value="ship">Ship</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="creationMetaRow" aria-label="Work setup">
|
||||
<input id="assetName" class="metaInput metaName" type="text" maxlength="32" placeholder="Name" aria-label="Name" />
|
||||
<input id="assetHeight" class="metaInput metaDimension" type="number" min="1" max="64" step="1" value="8" placeholder="H" aria-label="Height" />
|
||||
<span class="dimensionSeparator" aria-hidden="true">×</span>
|
||||
<input id="assetWidth" class="metaInput metaDimension" type="number" min="1" max="64" step="1" value="8" placeholder="W" aria-label="Width" />
|
||||
<select id="assetCategory" class="metaInput metaRole" aria-label="Role" required>
|
||||
<option value="" disabled selected>Role</option>
|
||||
<option value="human">Human</option>
|
||||
<option value="animal">Animal</option>
|
||||
<option value="bird">Bird</option>
|
||||
<option value="fish">Fish</option>
|
||||
<option value="nature">Nature</option>
|
||||
<option value="building">Building</option>
|
||||
<option value="ship">Ship</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label class="field compactNameField">Name
|
||||
<input id="assetName" type="text" maxlength="32" placeholder="Tiny bakery, round tree, island pup..." />
|
||||
</label>
|
||||
<div class="editorTop compactEditorTop">
|
||||
<div id="sideSwitcher" class="sideSwitcher" hidden aria-label="Sprite direction">
|
||||
<button id="editLeft" title="Left-facing sprite">◀ Left</button>
|
||||
|
|
@ -95,52 +89,72 @@
|
|||
</div>
|
||||
|
||||
<div class="paintLayout">
|
||||
<canvas id="paintCanvas" width="512" height="512" aria-label="Pixel editor"></canvas>
|
||||
<div class="palettePanel">
|
||||
<div id="paletteGrid" class="paletteGrid" aria-label="Color palette"></div>
|
||||
<div class="paintMainColumn">
|
||||
<canvas id="paintCanvas" width="512" height="512" aria-label="Pixel editor"></canvas>
|
||||
<div class="palettePanel">
|
||||
<div id="paletteGrid" class="paletteGrid" aria-label="Color palette"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawControlPanel">
|
||||
<div class="toolRow editorToolRow basicEditorTools" aria-label="Basic draw tools">
|
||||
<button id="toolBrush" class="tool toolBrush active" title="B">Draw</button>
|
||||
<button id="toolErase" class="tool toolErase" title="E">Erase</button>
|
||||
<button id="toolFill" class="tool toolFill" title="F">Fill</button>
|
||||
<button id="toolPick" class="tool toolPick" title="I">Pick</button>
|
||||
</div>
|
||||
<div class="toolRow editorUtilityRow">
|
||||
<button id="toggleAdvanced" class="tool toolAdvanced" type="button">+ Advanced</button>
|
||||
<button id="undoPaint" class="tool toolUndo" type="button" disabled>↶ Undo</button>
|
||||
<button id="redoPaint" class="tool toolRedo" type="button" disabled>↷ Redo</button>
|
||||
</div>
|
||||
<div class="advancedPanel">
|
||||
<div class="advancedPanelHeader">Advanced draw</div>
|
||||
<div id="advancedToolGroup" class="advancedToolGroup" hidden>
|
||||
<div class="toolRow advancedDrawRow">
|
||||
<button id="toolLine" class="tool toolLine advancedTool" title="L">Line</button>
|
||||
<button id="toolRect" class="tool toolRect advancedTool" title="R">Rect</button>
|
||||
<button id="toolSelect" class="tool toolSelect advancedTool" title="S">Select</button>
|
||||
<button id="toolDoor" class="tool toolDoor advancedTool buildingOnly">Door</button>
|
||||
<button id="clearPaint" class="tool danger advancedTool">Clear</button>
|
||||
</div>
|
||||
<div class="toolRow advancedActionRow">
|
||||
<button id="outlinePaint" class="tool toolOutline advancedTool" type="button">Outline</button>
|
||||
<button id="flipHorizontal" class="tool toolFlipH advancedTool" type="button">Flip H</button>
|
||||
<button id="flipVertical" class="tool toolFlipV advancedTool" type="button">Flip V</button>
|
||||
<button id="clearSelection" class="tool toolClearSel advancedTool" type="button" disabled>Clear Sel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="advancedRow extraAdvancedRow">
|
||||
<button id="toolLight" class="tool toolLight advancedOnly" hidden>Light</button>
|
||||
<button id="toolParticle" class="tool toolParticle advancedOnly" hidden>Particle</button>
|
||||
<button id="toolDepth" class="tool toolDepth advancedOnly" hidden>Depth</button>
|
||||
<button id="depthHigh" class="tool advancedOnly" type="button" hidden>High</button>
|
||||
<button id="depthLow" class="tool advancedOnly" type="button" hidden>Low</button>
|
||||
<div id="particleDirectionWrap" class="particleControl particleControlGroup advancedOnly" hidden>
|
||||
<label>Direction
|
||||
<select id="particleDirection">
|
||||
<option value="up" selected>↑ Up</option>
|
||||
<option value="down">↓ Down</option>
|
||||
<option value="left">← Left</option>
|
||||
<option value="right">→ Right</option>
|
||||
</select>
|
||||
</label>
|
||||
<small id="particleRangeStatus">Particle: paint cells like Light/Depth. Shift/right-click clears a cell.</small>
|
||||
</div>
|
||||
</div>
|
||||
<span id="advancedHint" class="hint" hidden></span>
|
||||
<input id="paintColor" type="color" value="#6bd06b" hidden />
|
||||
<div id="editHint" class="hint" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolRow editorToolRow">
|
||||
<button id="toolBrush" class="tool active" title="B">Draw</button>
|
||||
<button id="toolErase" class="tool" title="E">Erase</button>
|
||||
<button id="toolFill" class="tool" title="F">Fill</button>
|
||||
<button id="toolPick" class="tool" title="I">Pick</button>
|
||||
<button id="toolLine" class="tool" title="L">Line</button>
|
||||
<button id="toolRect" class="tool" title="R">Rect</button>
|
||||
<button id="toolSelect" class="tool" title="S">Select</button>
|
||||
<button id="toolDoor" class="tool buildingOnly">Door</button>
|
||||
<button id="clearPaint" class="tool danger">Clear</button>
|
||||
</div>
|
||||
<div class="toolRow editorHistoryRow">
|
||||
<button id="undoPaint" class="tool" type="button" disabled>Undo</button>
|
||||
<button id="redoPaint" class="tool" type="button" disabled>Redo</button>
|
||||
<button id="outlinePaint" class="tool" type="button">Outline</button>
|
||||
<button id="flipHorizontal" class="tool" type="button">Flip H</button>
|
||||
<button id="flipVertical" class="tool" type="button">Flip V</button>
|
||||
<button id="clearSelection" class="tool" type="button" disabled>Clear Sel</button>
|
||||
</div>
|
||||
<div class="advancedRow">
|
||||
<button id="toggleAdvanced" class="tool" type="button">Advanced Draw</button>
|
||||
<button id="toolLight" class="tool advancedOnly" hidden>Light</button>
|
||||
<button id="toolDepth" class="tool advancedOnly" hidden>Depth</button>
|
||||
<button id="toolParticle" class="tool advancedOnly" hidden>Particle</button>
|
||||
<button id="depthHigh" class="tool advancedOnly" type="button" hidden>High</button>
|
||||
<button id="depthLow" class="tool advancedOnly" type="button" hidden>Low</button>
|
||||
<button id="depthClear" class="tool advancedOnly" type="button" hidden>Clear</button>
|
||||
<span id="advancedHint" class="hint" hidden>Light, Particle, and Depth tools are here. Particle uses the selected palette color; Shift/right-click clears.</span>
|
||||
</div>
|
||||
<input id="paintColor" type="color" value="#6bd06b" hidden />
|
||||
<div id="editHint" class="hint">Palette color is used for pixels, lights, particles, and depth marks. Shortcuts: B/E/F/I/L/R/S, Ctrl/Cmd+Z, arrows move a selection.</div>
|
||||
</div>
|
||||
|
||||
<div class="card stack summonCard">
|
||||
<div class="cardTitle">Finish</div>
|
||||
<div class="actionRow finishActions">
|
||||
<button id="saveAndPlace" class="primary bigPrimary">Save + Place on Island</button>
|
||||
<button id="saveAsset" class="secondary">Save to Library</button>
|
||||
<button id="checkOnIsland" class="secondary">Check on Island</button>
|
||||
<button id="newAsset" class="secondary">New</button>
|
||||
<button id="saveAndPlace" class="primary bigPrimary"><span>Publish</span><span id="finishQuotaBadge" class="quotaBadge finishQuotaBadge" aria-live="polite"></span></button>
|
||||
<button id="saveAsset" class="secondary">Save to Collection</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="lineageNote" class="lineageNote"></div>
|
||||
|
|
@ -149,10 +163,10 @@
|
|||
<section id="tab-library" class="tabPanel">
|
||||
<div class="card stack">
|
||||
<div class="libraryHead">
|
||||
<div class="cardTitle">Library</div>
|
||||
<div class="cardTitle">Collection</div>
|
||||
<button id="showHiddenAssets" class="tool" type="button">Hidden</button>
|
||||
</div>
|
||||
<p class="hint">Your works are permanent here. The island is a temporary exhibition: place your own new or past works; remix only copies lineage and pixels to the canvas.</p>
|
||||
<p class="hint">Your works are permanent here. The island is a temporary exhibition.</p>
|
||||
<div id="hiddenAssetPanel" class="hiddenAssetPanel" hidden>
|
||||
<div class="cardTitle">Hidden</div>
|
||||
<div id="hiddenAssetList" class="assetList compactAssetList"></div>
|
||||
|
|
@ -162,28 +176,9 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-data" class="tabPanel">
|
||||
<section id="tab-settings" class="tabPanel">
|
||||
<div class="card stack">
|
||||
<div class="cardTitle">Local save</div>
|
||||
<p class="hint">Works are durable library assets; island placements are temporary exhibition objects. Export JSON if you want to move or test a local world.</p>
|
||||
<div class="actionRow wrap">
|
||||
<button id="exportData">Export full JSON</button>
|
||||
<button id="exportCompact">Export compact</button>
|
||||
<button id="exportSnapshot">Export snapshot</button>
|
||||
<button id="exportAssetBundle">Export asset bundle</button>
|
||||
<button id="importData">Import JSON</button>
|
||||
<button id="exportPng" type="button">Export PNG</button>
|
||||
<button id="importPng" type="button">Import PNG</button>
|
||||
<input id="pngImportInput" type="file" accept="image/png,image/*" hidden />
|
||||
<button id="resetAll" class="danger">Reset all</button>
|
||||
</div>
|
||||
<div id="syncStats" class="syncStats"></div>
|
||||
<textarea id="dataBox" rows="10" placeholder="Exported JSON appears here."></textarea>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card stack">
|
||||
<div class="cardTitle">Visual settings</div>
|
||||
<div class="cardTitle">Settings</div>
|
||||
<p class="hint">Turn major visual systems on or off. Server decides the public 250 exhibition slots; this local cap only filters your view.</p>
|
||||
<div class="toggleList">
|
||||
<label class="checkRow"><input id="settingLights" type="checkbox" checked /> <span>Lights & glow</span></label>
|
||||
|
|
@ -195,18 +190,8 @@
|
|||
</div>
|
||||
<div id="rotationStats" class="syncStats"></div>
|
||||
</div>
|
||||
|
||||
<div class="card stack">
|
||||
<div class="cardTitle">Phase 5 guardrails</div>
|
||||
<p class="hint">Local pre-server checks for object volume, orphaned placements, terrain compatibility, hidden objects, and report logs.</p>
|
||||
<div id="guardrailStats" class="syncStats"></div>
|
||||
<div class="actionRow wrap">
|
||||
<button id="validateWorld" type="button">Validate world</button>
|
||||
<button id="exportModerationReport" type="button">Export moderation report</button>
|
||||
<button id="clearReports" class="danger" type="button">Clear reports</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -221,13 +206,28 @@
|
|||
<button id="voteDown" type="button">▼</button>
|
||||
</div>
|
||||
<div class="bubbleActions">
|
||||
<button id="bubbleRemix" type="button">Remix</button>
|
||||
<button id="bubbleRemix" class="bubbleRemixPrimary" type="button">Remix</button>
|
||||
<button id="bubbleTeleport" type="button" hidden>Teleport to source</button>
|
||||
</div>
|
||||
<button id="bubbleMenu" class="bubbleMenuButton" type="button">Menu</button>
|
||||
<div id="bubbleMenuActions" class="bubbleMenuActions" hidden>
|
||||
<button id="bubbleReport" type="button">Report</button>
|
||||
<button id="bubbleHide" type="button" hidden>Hide</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
|
||||
<div id="confirmDialog" class="reportDialog" hidden>
|
||||
<div class="reportCard" role="dialog" aria-modal="true" aria-labelledby="confirmTitle">
|
||||
<div id="confirmTitle" class="cardTitle">Delete work</div>
|
||||
<p id="confirmMessage" class="hint"></p>
|
||||
<div class="actionRow wrap reportActions">
|
||||
<button id="confirmOk" class="danger" type="button">Delete</button>
|
||||
<button id="confirmCancel" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="reportDialog" class="reportDialog" hidden>
|
||||
<div class="reportCard" role="dialog" aria-modal="true" aria-labelledby="reportTitle">
|
||||
<div id="reportTitle" class="cardTitle">Report object</div>
|
||||
|
|
@ -249,7 +249,11 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script src="./js/editor-actions.js"></script>
|
||||
<script src="./js/core-utils.js"></script>
|
||||
<script src="./js/module-loader.js"></script>
|
||||
<script src="./js/state-index.js"></script>
|
||||
<script src="./js/rotation-policy.js"></script>
|
||||
<script src="./js/lighting.js"></script>
|
||||
<script src="./js/phase2-sync.js"></script>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
|
|
|
|||
203
index.html.bak
203
index.html.bak
|
|
@ -1,203 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Pixel Island Summoner - Local Prototype</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<canvas id="worldCanvas" aria-label="Island map"></canvas>
|
||||
|
||||
<button id="openEditor" class="edgeAdd" title="Open Pixel Studio">+</button>
|
||||
|
||||
<header class="hud topHud">
|
||||
<div>
|
||||
<div class="logo">Pixel Island</div>
|
||||
<div class="subline">Local prototype / Draw pixels, summon them to islands.</div>
|
||||
</div>
|
||||
<label class="authorCard">
|
||||
<span>Your Name</span>
|
||||
<input id="authorName" type="text" maxlength="24" value="Local Artist" />
|
||||
</label>
|
||||
<div class="clockCard" aria-label="world clock">
|
||||
<div id="phaseLabel" class="phaseLabel">Day</div>
|
||||
<div class="phaseBar"><span id="phaseBar"></span></div>
|
||||
<div class="clockHint">10 min = 1 day</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside class="hud toolHud">
|
||||
<button id="modeInspect" class="iconButton active" title="Pan / Inspect">Pan</button>
|
||||
<button id="modePlace" class="iconButton" title="Summon selected asset">Place</button>
|
||||
<button id="modeErase" class="iconButton danger" title="Remove asset">Erase</button>
|
||||
<div class="divider"></div>
|
||||
<button id="zoomOut" class="iconButton" title="Zoom out">−</button>
|
||||
<button id="zoomIn" class="iconButton" title="Zoom in">+</button>
|
||||
<button id="resetView" class="iconButton" title="Reset view">Reset</button>
|
||||
</aside>
|
||||
|
||||
<section id="studioDrawer" class="studioDrawer" aria-label="Pixel Studio">
|
||||
<div class="drawerHead">
|
||||
<div>
|
||||
<h1>Pixel Studio</h1>
|
||||
<p>Make static scenery or moving island life.</p>
|
||||
</div>
|
||||
<button id="closeEditor" class="ghostButton" title="Close">×</button>
|
||||
</div>
|
||||
|
||||
<nav class="tabs" aria-label="Studio tabs">
|
||||
<button class="tab active" data-tab="draw">Draw</button>
|
||||
<button class="tab" data-tab="library">Library</button>
|
||||
<button class="tab" data-tab="data">Data</button>
|
||||
</nav>
|
||||
|
||||
<div class="drawerBody">
|
||||
<section id="tab-draw" class="tabPanel active">
|
||||
<div class="card editorCard heroEditor">
|
||||
<div class="quickSetupBar twoCols">
|
||||
<label class="field">Canvas size
|
||||
<select id="assetSize">
|
||||
<option value="8" selected>8×8</option>
|
||||
<option value="16">16×16</option>
|
||||
<option value="32">32×32</option>
|
||||
<option value="64">64×64</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">Role
|
||||
<select id="assetCategory">
|
||||
<option value="human">Human</option>
|
||||
<option value="animal">Animal</option>
|
||||
<option value="nature" selected>Nature</option>
|
||||
<option value="building">Building</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="field compactNameField">Name
|
||||
<input id="assetName" type="text" maxlength="32" placeholder="Tiny bakery, round tree, island pup..." />
|
||||
</label>
|
||||
<div class="editorTop compactEditorTop">
|
||||
<div id="sideSwitcher" class="sideSwitcher" hidden aria-label="Sprite direction">
|
||||
<button id="editLeft" title="Left-facing sprite">◀ Left</button>
|
||||
<button id="editRight" class="active" title="Right-facing sprite is the main/front direction">▶ Right</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="paintLayout">
|
||||
<canvas id="paintCanvas" width="512" height="512" aria-label="Pixel editor"></canvas>
|
||||
<div class="palettePanel">
|
||||
<div id="paletteGrid" class="paletteGrid" aria-label="Color palette"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolRow editorToolRow">
|
||||
<button id="toolBrush" class="tool active" title="B">Draw</button>
|
||||
<button id="toolErase" class="tool" title="E">Erase</button>
|
||||
<button id="toolFill" class="tool" title="F">Fill</button>
|
||||
<button id="toolPick" class="tool" title="I">Pick</button>
|
||||
<button id="toolLine" class="tool" title="L">Line</button>
|
||||
<button id="toolRect" class="tool" title="R">Rect</button>
|
||||
<button id="toolSelect" class="tool" title="S">Select</button>
|
||||
<button id="toolDoor" class="tool buildingOnly">Door</button>
|
||||
<button id="clearPaint" class="tool danger">Clear</button>
|
||||
</div>
|
||||
<div class="toolRow editorHistoryRow">
|
||||
<button id="undoPaint" class="tool" type="button" disabled>Undo</button>
|
||||
<button id="redoPaint" class="tool" type="button" disabled>Redo</button>
|
||||
<button id="outlinePaint" class="tool" type="button">Outline</button>
|
||||
<button id="flipHorizontal" class="tool" type="button">Flip H</button>
|
||||
<button id="flipVertical" class="tool" type="button">Flip V</button>
|
||||
</div>
|
||||
<div class="toolRow editorMoveRow">
|
||||
<button id="nudgeLeft" class="tool" type="button">←</button>
|
||||
<button id="nudgeUp" class="tool" type="button">↑</button>
|
||||
<button id="nudgeDown" class="tool" type="button">↓</button>
|
||||
<button id="nudgeRight" class="tool" type="button">→</button>
|
||||
<button id="clearSelection" class="tool" type="button" disabled>Clear Sel</button>
|
||||
</div>
|
||||
<div class="toolRow editorFileRow">
|
||||
<button id="exportPng" class="tool" type="button">Export PNG</button>
|
||||
<button id="importPng" class="tool" type="button">Import PNG</button>
|
||||
<input id="pngImportInput" type="file" accept="image/png,image/*" hidden />
|
||||
</div>
|
||||
<div class="advancedRow">
|
||||
<button id="toggleAdvanced" class="tool" type="button">Advanced Draw</button>
|
||||
<button id="toolLight" class="tool advancedOnly" hidden>Light</button>
|
||||
<button id="toolDepth" class="tool advancedOnly" hidden>Depth</button>
|
||||
<span id="advancedHint" class="hint" hidden>Light/Depth tools are here. Depth paints visible blue height marks; Shift/right-click erases.</span>
|
||||
</div>
|
||||
<input id="paintColor" type="color" value="#6bd06b" hidden />
|
||||
<div id="editHint" class="hint">Palette color is used for pixels, lights, and depth marks. Shortcuts: B/E/F/I/L/R/S, Ctrl/Cmd+Z, arrows move a selection.</div>
|
||||
</div>
|
||||
|
||||
<div class="card stack summonCard">
|
||||
<div class="cardTitle">Finish</div>
|
||||
<div class="actionRow finishActions">
|
||||
<button id="saveAndPlace" class="primary bigPrimary">Save + Summon on Map</button>
|
||||
<button id="saveAsset" class="secondary">Save only</button>
|
||||
<button id="newAsset" class="secondary">New</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="lineageNote" class="lineageNote"></div>
|
||||
</section>
|
||||
|
||||
<section id="tab-library" class="tabPanel">
|
||||
<div class="card stack">
|
||||
<div class="libraryHead">
|
||||
<div class="cardTitle">Library</div>
|
||||
<button id="showHiddenAssets" class="tool" type="button">Hidden</button>
|
||||
</div>
|
||||
<p class="hint">Click an asset to select it and jump to it on the map. Copy Edit creates a new derivative.</p>
|
||||
<div id="hiddenAssetPanel" class="hiddenAssetPanel" hidden>
|
||||
<div class="cardTitle">Hidden assets</div>
|
||||
<div id="hiddenAssetList" class="assetList compactAssetList"></div>
|
||||
</div>
|
||||
<div id="assetList" class="assetList"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-data" class="tabPanel">
|
||||
<div class="card stack">
|
||||
<div class="cardTitle">Local save</div>
|
||||
<p class="hint">Saved to this browser only. Export JSON if you want to move or share a local world.</p>
|
||||
<div class="actionRow wrap">
|
||||
<button id="exportData">Export full JSON</button>
|
||||
<button id="exportCompact">Export compact</button>
|
||||
<button id="exportSnapshot">Export snapshot</button>
|
||||
<button id="exportAssetBundle">Export asset bundle</button>
|
||||
<button id="importData">Import JSON</button>
|
||||
<button id="resetAll" class="danger">Reset all</button>
|
||||
</div>
|
||||
<div id="syncStats" class="syncStats"></div>
|
||||
<textarea id="dataBox" rows="10" placeholder="Exported JSON appears here."></textarea>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="selectionBubble" class="selectionBubble" hidden>
|
||||
<div id="bubbleName" class="bubbleName">Name</div>
|
||||
<div id="bubbleAuthor" class="bubbleAuthor">by Author</div>
|
||||
<div id="bubbleRemixFrom" class="bubbleRemixFrom" hidden></div>
|
||||
<div id="bubbleRemixCount" class="bubbleRemixCount">Remixed: 0</div>
|
||||
<div class="bubbleVotes">
|
||||
<button id="voteUp" type="button">▲</button>
|
||||
<span id="voteScore">0</span>
|
||||
<button id="voteDown" type="button">▼</button>
|
||||
</div>
|
||||
<div class="bubbleActions">
|
||||
<button id="bubbleRemix" type="button">Remix</button>
|
||||
<button id="bubbleHide" type="button" hidden>Hide</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
</div>
|
||||
|
||||
<script src="./js/editor-actions.js"></script>
|
||||
<script src="./js/phase2-sync.js"></script>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
33
js/core-utils.js
Normal file
33
js/core-utils.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
const root = window.PixelIslandModules ||= {};
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function mod(n, m) {
|
||||
return ((n % m) + m) % m;
|
||||
}
|
||||
|
||||
function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function fnv1a(value) {
|
||||
let hash = 0x811c9dc5;
|
||||
const text = String(value);
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
function uid() {
|
||||
return Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-5);
|
||||
}
|
||||
|
||||
root.CoreUtils = { clamp, mod, lerp, fnv1a, uid };
|
||||
})();
|
||||
|
|
@ -7,9 +7,11 @@
|
|||
return Array.isArray(pixels) ? [...pixels] : [];
|
||||
}
|
||||
|
||||
function floodFill(sourcePixels, size, x, y, colorCode) {
|
||||
function floodFill(sourcePixels, width, x, y, colorCode, height = width) {
|
||||
const w = Math.max(1, Math.round(Number(width) || 1));
|
||||
const h = Math.max(1, Math.round(Number(height) || w));
|
||||
const pixels = clonePixels(sourcePixels);
|
||||
const target = pixels[y * size + x] || null;
|
||||
const target = pixels[y * w + x] || null;
|
||||
const replacement = colorCode || null;
|
||||
if (target === replacement) return { pixels, changed: false, count: 0, cells: [] };
|
||||
|
||||
|
|
@ -17,8 +19,8 @@
|
|||
const cells = [];
|
||||
while (stack.length) {
|
||||
const [cx, cy] = stack.pop();
|
||||
if (cx < 0 || cy < 0 || cx >= size || cy >= size) continue;
|
||||
const index = cy * size + cx;
|
||||
if (cx < 0 || cy < 0 || cx >= w || cy >= h) continue;
|
||||
const index = cy * w + cx;
|
||||
if ((pixels[index] || null) !== target) continue;
|
||||
pixels[index] = replacement;
|
||||
cells.push({ x: cx, y: cy });
|
||||
|
|
|
|||
136
js/lighting.js
136
js/lighting.js
|
|
@ -2,67 +2,115 @@
|
|||
'use strict';
|
||||
|
||||
const root = window.PixelIslandModules ||= {};
|
||||
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
||||
const lerp = (a, b, t) => a + (b - a) * t;
|
||||
const mod = (n, m) => ((n % m) + m) % m;
|
||||
const utils = root.CoreUtils || {};
|
||||
const clamp = utils.clamp || ((value, min, max) => Math.max(min, Math.min(max, value)));
|
||||
const lerp = utils.lerp || ((a, b, t) => a + (b - a) * t);
|
||||
const mod = utils.mod || ((n, m) => ((n % m) + m) % m);
|
||||
|
||||
function getCelestialLightForMinute(minute) {
|
||||
const isNight = minute >= 6;
|
||||
const local = isNight ? (minute - 6) / 4 : minute / 6;
|
||||
const t = clamp(local, 0, 1);
|
||||
const eased = t * t * (3 - 2 * t);
|
||||
const elevation = Math.max(.08, Math.sin(t * Math.PI));
|
||||
const sourceX = isNight ? lerp(-1.05, 1.05, eased) : lerp(1.18, -1.18, eased);
|
||||
const sourceY = isNight ? -.46 - elevation * .24 : -.58 - elevation * .34;
|
||||
return {
|
||||
x: sourceX,
|
||||
y: sourceY,
|
||||
elevation,
|
||||
isNight,
|
||||
shadeAlpha: isNight ? .18 : lerp(.20, .08, elevation)
|
||||
};
|
||||
function smooth(t) {
|
||||
const n = clamp(t, 0, 1);
|
||||
return n * n * (3 - 2 * n);
|
||||
}
|
||||
|
||||
function getShadowForMinute(minute) {
|
||||
const light = getCelestialLightForMinute(minute);
|
||||
const length = lerp(light.isNight ? 1.35 : 1.9, light.isNight ? .85 : .52, light.elevation);
|
||||
return {
|
||||
x: -light.x * 6.5 * length,
|
||||
y: Math.min(-1.8, light.y * 4.2 * length),
|
||||
length,
|
||||
alpha: light.isNight ? .085 : lerp(.27, .12, light.elevation)
|
||||
};
|
||||
const m = clamp(Number(minute) || 0, 0, 10);
|
||||
let dirX = -0.9;
|
||||
let length = 1.2;
|
||||
let alpha = 0;
|
||||
let scaleY = 0.26;
|
||||
|
||||
if (m < 0.65) {
|
||||
const t = smooth(m / 0.65);
|
||||
dirX = lerp(0.42, 0.18, t);
|
||||
length = lerp(1.05, 0.92, t);
|
||||
alpha = lerp(0.12, 0.0, t);
|
||||
scaleY = lerp(0.22, 0.18, t);
|
||||
} else if (m < 1.35) {
|
||||
const t = smooth((m - 0.65) / 0.70);
|
||||
dirX = lerp(-1.05, -0.82, t);
|
||||
length = lerp(1.38, 1.12, t);
|
||||
alpha = lerp(0.0, 0.27, t);
|
||||
scaleY = lerp(0.22, 0.27, t);
|
||||
} else if (m < 5.65) {
|
||||
const t = smooth((m - 1.35) / 4.30);
|
||||
const elevation = Math.sin(t * Math.PI);
|
||||
dirX = lerp(-0.82, 0.98, t);
|
||||
length = lerp(1.12, 1.34, t) - elevation * 0.48;
|
||||
alpha = lerp(0.27, 0.20, elevation);
|
||||
scaleY = 0.24 + 0.06 * (1 - elevation);
|
||||
} else if (m < 6.40) {
|
||||
const t = smooth((m - 5.65) / 0.75);
|
||||
dirX = lerp(0.98, 1.08, t);
|
||||
length = lerp(1.34, 1.56, t);
|
||||
alpha = lerp(0.20, 0.0, t);
|
||||
scaleY = lerp(0.28, 0.20, t);
|
||||
} else if (m < 7.25) {
|
||||
const t = smooth((m - 6.40) / 0.85);
|
||||
dirX = lerp(0.20, -0.36, t);
|
||||
length = lerp(0.92, 1.08, t);
|
||||
alpha = lerp(0.0, 0.12, t);
|
||||
scaleY = lerp(0.18, 0.23, t);
|
||||
} else if (m < 9.20) {
|
||||
const t = smooth((m - 7.25) / 1.95);
|
||||
dirX = lerp(-0.36, -0.58, t);
|
||||
length = lerp(1.08, 1.18, t);
|
||||
alpha = 0.12;
|
||||
scaleY = 0.23;
|
||||
} else {
|
||||
const t = smooth((m - 9.20) / 0.80);
|
||||
dirX = lerp(-0.58, 0.42, t);
|
||||
length = lerp(1.18, 1.05, t);
|
||||
alpha = lerp(0.12, 0.0, t);
|
||||
scaleY = lerp(0.23, 0.18, t);
|
||||
}
|
||||
|
||||
return { dirX, length, skewX: dirX * (0.72 + 0.32 * length), scaleY, alpha };
|
||||
}
|
||||
|
||||
function getPhase(dayMs, now = Date.now()) {
|
||||
const t = mod(now, dayMs);
|
||||
function getPhase(options) {
|
||||
const { dayMs, now, dayNightEnabled, mixHex } = options;
|
||||
if (dayNightEnabled === false) {
|
||||
return { key: 'day', label: 'Day', progress: 0.25, sky: '#86d5ff', darkness: 0, darkOverlay: 'rgba(12, 19, 45, 0)', tint: 'rgba(255,255,255,0)', tintAlpha: 0, shadow: getShadowForMinute(3) };
|
||||
}
|
||||
const t = mod(now || Date.now(), dayMs);
|
||||
const minute = t / 60000;
|
||||
const stops = [
|
||||
{ at: 0, key: 'morning', label: 'Morning', darkness: 0.18, tint: [255, 208, 144, 0.12] },
|
||||
{ at: 1, key: 'day', label: 'Day', darkness: 0.00, tint: [255, 255, 255, 0.00] },
|
||||
{ at: 5, key: 'day', label: 'Day', darkness: 0.00, tint: [255, 255, 255, 0.00] },
|
||||
{ at: 6, key: 'evening', label: 'Evening', darkness: 0.18, tint: [255, 146, 114, 0.14] },
|
||||
{ at: 10, key: 'night', label: 'Night', darkness: 0.48, tint: [36, 47, 96, 0.18] }
|
||||
{ at: 0.00, key: 'preDawn', label: 'Night', sky: '#17254e', darkness: 0.52, tint: [22, 28, 66, 0.10], overlay: [12, 19, 45] },
|
||||
{ at: 0.65, key: 'dawn', label: 'Morning', sky: '#5969aa', darkness: 0.30, tint: [120, 100, 150, 0.10], overlay: [40, 43, 78] },
|
||||
{ at: 1.35, key: 'morning', label: 'Morning', sky: '#f6b16b', darkness: 0.12, tint: [255, 195, 126, 0.13], overlay: [114, 64, 36] },
|
||||
{ at: 2.15, key: 'day', label: 'Day', sky: '#8ad8ff', darkness: 0.00, tint: [255, 255, 255, 0.00], overlay: [12, 19, 45] },
|
||||
{ at: 4.75, key: 'day', label: 'Day', sky: '#8ad8ff', darkness: 0.00, tint: [255, 255, 255, 0.00], overlay: [12, 19, 45] },
|
||||
{ at: 5.65, key: 'evening', label: 'Evening', sky: '#ffa153', darkness: 0.07, tint: [255, 166, 88, 0.10], overlay: [130, 48, 18] },
|
||||
{ at: 6.40, key: 'evening', label: 'Evening', sky: '#d55a2d', darkness: 0.20, tint: [255, 118, 62, 0.17], overlay: [118, 42, 18] },
|
||||
{ at: 7.25, key: 'night', label: 'Night', sky: '#1b2a58', darkness: 0.46, tint: [32, 40, 82, 0.07], overlay: [12, 19, 45] },
|
||||
{ at: 9.20, key: 'night', label: 'Night', sky: '#132247', darkness: 0.58, tint: [16, 24, 58, 0.10], overlay: [12, 19, 45] },
|
||||
{ at: 10.00, key: 'preDawn', label: 'Night', sky: '#17254e', darkness: 0.52, tint: [22, 28, 66, 0.10], overlay: [12, 19, 45] }
|
||||
];
|
||||
let a = stops[0], b = stops[1];
|
||||
for (let i = 0; i < stops.length - 1; i++) {
|
||||
if (minute >= stops[i].at && minute < stops[i + 1].at) { a = stops[i]; b = stops[i + 1]; break; }
|
||||
if (minute >= 6) { a = stops[3]; b = stops[4]; }
|
||||
if (minute >= stops[i].at && minute < stops[i + 1].at) {
|
||||
a = stops[i];
|
||||
b = stops[i + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
const localT = clamp((minute - a.at) / Math.max(0.0001, b.at - a.at), 0, 1);
|
||||
const eased = localT * localT * (3 - 2 * localT);
|
||||
const tint = a.tint.map((v, i) => lerp(v, b.tint[i], eased));
|
||||
const label = minute < 1 ? 'Morning' : minute < 5 ? 'Day' : minute < 6 ? 'Evening' : 'Night';
|
||||
const eased = smooth((minute - a.at) / Math.max(0.0001, b.at - a.at));
|
||||
const tint = a.tint.map((value, i) => lerp(value, b.tint[i], eased));
|
||||
const overlay = a.overlay.map((value, i) => lerp(value, b.overlay[i], eased));
|
||||
const darkness = lerp(a.darkness, b.darkness, eased);
|
||||
const dominantKey = darkness >= 0.34 ? 'night' : (a.key === 'evening' || b.key === 'evening' ? 'evening' : (a.key === 'morning' || b.key === 'morning' || a.key === 'dawn' || b.key === 'dawn' ? 'morning' : 'day'));
|
||||
return {
|
||||
key: label.toLowerCase(),
|
||||
label,
|
||||
key: dominantKey,
|
||||
label: dominantKey === 'night' ? 'Night' : dominantKey === 'evening' ? 'Evening' : dominantKey === 'morning' ? 'Morning' : 'Day',
|
||||
progress: t / dayMs,
|
||||
darkness: lerp(a.darkness, b.darkness, eased),
|
||||
sky: mixHex(a.sky, b.sky, eased),
|
||||
darkness,
|
||||
darkOverlay: `rgba(${Math.round(overlay[0])}, ${Math.round(overlay[1])}, ${Math.round(overlay[2])}, ${darkness.toFixed(3)})`,
|
||||
tint: `rgba(${Math.round(tint[0])}, ${Math.round(tint[1])}, ${Math.round(tint[2])}, ${tint[3].toFixed(3)})`,
|
||||
light: getCelestialLightForMinute(minute),
|
||||
tintAlpha: tint[3],
|
||||
shadow: getShadowForMinute(minute)
|
||||
};
|
||||
}
|
||||
|
||||
root.Lighting = { getPhase, getShadowForMinute, getCelestialLightForMinute };
|
||||
root.Lighting = { getPhase, getShadowForMinute };
|
||||
})();
|
||||
|
|
|
|||
22
js/module-loader.js
Normal file
22
js/module-loader.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
const root = window.PixelIslandModules ||= {};
|
||||
const pending = new Map();
|
||||
|
||||
function loadScript(src) {
|
||||
if (pending.has(src)) return pending.get(src);
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.defer = true;
|
||||
script.onload = () => resolve(script);
|
||||
script.onerror = () => reject(new Error(`Could not load ${src}`));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
pending.set(src, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
root.ModuleLoader = { loadScript };
|
||||
})();
|
||||
|
|
@ -2,21 +2,134 @@
|
|||
'use strict';
|
||||
|
||||
const root = window.PixelIslandModules ||= {};
|
||||
const FORMAT = 'pixel-island-phase2-compact-v1';
|
||||
const FORMAT = 'pixel-island-phase2-compact-v2';
|
||||
const FORMAT_V1 = 'pixel-island-phase2-compact-v1';
|
||||
const SNAPSHOT_FORMAT = 'pixel-island-phase2-snapshot-v1';
|
||||
const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v1';
|
||||
const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v2';
|
||||
const ASSET_BUNDLE_FORMAT_V1 = 'pixel-island-phase2-asset-bundle-v1';
|
||||
const EVENT_LOG_LIMIT = 300;
|
||||
const DB_NAME = 'pixel-island-phase2-cache';
|
||||
const DB_VERSION = 1;
|
||||
const ASSET_STORE = 'assets';
|
||||
const SNAPSHOT_STORE = 'snapshots';
|
||||
const COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const PACK6_CHARS = `.${COLOR_CODES}`;
|
||||
|
||||
function isCompactState(value) {
|
||||
return Boolean(value && value.format === FORMAT && Array.isArray(value.assets));
|
||||
return Boolean(value && (value.format === FORMAT || value.format === FORMAT_V1) && Array.isArray(value.assets));
|
||||
}
|
||||
|
||||
function isAssetBundle(value) {
|
||||
return Boolean(value && value.format === ASSET_BUNDLE_FORMAT && Array.isArray(value.assets));
|
||||
return Boolean(value && (value.format === ASSET_BUNDLE_FORMAT || value.format === ASSET_BUNDLE_FORMAT_V1) && Array.isArray(value.assets));
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes) {
|
||||
if (!bytes || !bytes.length) return '';
|
||||
if (typeof btoa === 'function') {
|
||||
let binary = '';
|
||||
const chunk = 8192;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode(...bytes.slice(i, i + chunk));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
if (typeof Buffer !== 'undefined') return Buffer.from(bytes).toString('base64');
|
||||
return '';
|
||||
}
|
||||
|
||||
function base64ToBytes(value) {
|
||||
const text = String(value || '');
|
||||
if (!text) return [];
|
||||
if (typeof atob === 'function') {
|
||||
const binary = atob(text);
|
||||
const out = new Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) & 255;
|
||||
return out;
|
||||
}
|
||||
if (typeof Buffer !== 'undefined') return Array.from(Buffer.from(text, 'base64'));
|
||||
return [];
|
||||
}
|
||||
|
||||
function pack6Index(ch) {
|
||||
const index = PACK6_CHARS.indexOf(ch || '.');
|
||||
return index >= 0 && index < 64 ? index : 0;
|
||||
}
|
||||
|
||||
function unpack6Index(value) {
|
||||
return PACK6_CHARS[value] || '.';
|
||||
}
|
||||
|
||||
function canPack6(text) {
|
||||
for (const ch of String(text || '')) if (PACK6_CHARS.indexOf(ch) < 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function pack6Text(input) {
|
||||
const text = String(input || '');
|
||||
if (!text) return '';
|
||||
let buffer = 0;
|
||||
let bitCount = 0;
|
||||
const bytes = [];
|
||||
for (const ch of text) {
|
||||
buffer = (buffer << 6) | pack6Index(ch);
|
||||
bitCount += 6;
|
||||
while (bitCount >= 8) {
|
||||
bitCount -= 8;
|
||||
bytes.push((buffer >> bitCount) & 255);
|
||||
buffer &= (1 << bitCount) - 1;
|
||||
}
|
||||
}
|
||||
if (bitCount > 0) bytes.push((buffer << (8 - bitCount)) & 255);
|
||||
return bytesToBase64(bytes);
|
||||
}
|
||||
|
||||
function unpack6Text(input, length) {
|
||||
const target = Math.max(0, Number(length) || 0);
|
||||
if (!target) return '';
|
||||
const bytes = base64ToBytes(input);
|
||||
let buffer = 0;
|
||||
let bitCount = 0;
|
||||
let out = '';
|
||||
for (const byte of bytes) {
|
||||
buffer = (buffer << 8) | (byte & 255);
|
||||
bitCount += 8;
|
||||
while (bitCount >= 6 && out.length < target) {
|
||||
bitCount -= 6;
|
||||
out += unpack6Index((buffer >> bitCount) & 63);
|
||||
buffer &= (1 << bitCount) - 1;
|
||||
}
|
||||
if (out.length >= target) break;
|
||||
}
|
||||
return (out + '.'.repeat(target)).slice(0, target);
|
||||
}
|
||||
|
||||
function packBitMask(mask) {
|
||||
const bits = Array.from(mask || [], Boolean);
|
||||
if (!bits.length) return '';
|
||||
const bytes = [];
|
||||
let current = 0;
|
||||
for (let i = 0; i < bits.length; i++) {
|
||||
current = (current << 1) | (bits[i] ? 1 : 0);
|
||||
if (i % 8 === 7) {
|
||||
bytes.push(current & 255);
|
||||
current = 0;
|
||||
}
|
||||
}
|
||||
const rest = bits.length % 8;
|
||||
if (rest) bytes.push((current << (8 - rest)) & 255);
|
||||
return bytesToBase64(bytes);
|
||||
}
|
||||
|
||||
function unpackBitMask(input, length) {
|
||||
const target = Math.max(0, Number(length) || 0);
|
||||
const bytes = base64ToBytes(input);
|
||||
const out = [];
|
||||
for (const byte of bytes) {
|
||||
for (let bit = 7; bit >= 0 && out.length < target; bit--) out.push(Boolean((byte >> bit) & 1));
|
||||
if (out.length >= target) break;
|
||||
}
|
||||
while (out.length < target) out.push(false);
|
||||
return out;
|
||||
}
|
||||
|
||||
function rleEncode(input) {
|
||||
|
|
@ -55,21 +168,45 @@
|
|||
return out;
|
||||
}
|
||||
|
||||
function normalizeEncodedPlane(value, size, emptyChar = '.') {
|
||||
const total = Math.max(1, Number(size) || 1) ** 2;
|
||||
function assetWidth(asset) {
|
||||
const size = Math.max(1, Number(asset?.size) || 16);
|
||||
return Math.max(1, Number(asset?.width ?? asset?.w ?? size) || size);
|
||||
}
|
||||
|
||||
function assetHeight(asset) {
|
||||
const size = Math.max(1, Number(asset?.size) || 16);
|
||||
return Math.max(1, Number(asset?.height ?? asset?.ht ?? size) || size);
|
||||
}
|
||||
|
||||
function normalizeEncodedPlane(value, width, emptyChar = '.', height = width) {
|
||||
const w = Math.max(1, Number(width) || 1);
|
||||
const h = Math.max(1, Number(height) || w);
|
||||
const total = w * h;
|
||||
const source = typeof value === 'string' ? value : Array.isArray(value) ? value.map((v) => v || emptyChar).join('') : '';
|
||||
return (source + emptyChar.repeat(total)).slice(0, total);
|
||||
}
|
||||
|
||||
function cropPlane(encoded, size, emptyChar = '.') {
|
||||
const text = normalizeEncodedPlane(encoded, size, emptyChar);
|
||||
let minX = size;
|
||||
let minY = size;
|
||||
function chooseSmallest(candidates) {
|
||||
let best = candidates[0];
|
||||
let bestSize = JSON.stringify(best).length;
|
||||
for (const candidate of candidates.slice(1)) {
|
||||
const size = JSON.stringify(candidate).length;
|
||||
if (size < bestSize) { best = candidate; bestSize = size; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function cropPlane(encoded, width, emptyChar = '.', options = {}, height = width) {
|
||||
const w0 = Math.max(1, Number(width) || 1);
|
||||
const h0 = Math.max(1, Number(height) || w0);
|
||||
const text = normalizeEncodedPlane(encoded, w0, emptyChar, h0);
|
||||
let minX = w0;
|
||||
let minY = h0;
|
||||
let maxX = -1;
|
||||
let maxY = -1;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
if (text[y * size + x] !== emptyChar) {
|
||||
for (let y = 0; y < h0; y++) {
|
||||
for (let x = 0; x < w0; x++) {
|
||||
if (text[y * w0 + x] !== emptyChar) {
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
|
|
@ -81,49 +218,76 @@
|
|||
const w = maxX - minX + 1;
|
||||
const h = maxY - minY + 1;
|
||||
let cropped = '';
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
cropped += text.slice(y * size + minX, y * size + minX + w);
|
||||
}
|
||||
const rle = rleEncode(cropped);
|
||||
return rle.length < cropped.length ? { b: [minX, minY, w, h], e: 'rle', v: rle } : { b: [minX, minY, w, h], e: 'raw', v: cropped };
|
||||
for (let y = minY; y <= maxY; y++) cropped += text.slice(y * w0 + minX, y * w0 + minX + w);
|
||||
const candidates = [
|
||||
{ b: [minX, minY, w, h], e: 'raw', v: cropped },
|
||||
{ b: [minX, minY, w, h], e: 'rle', v: rleEncode(cropped) }
|
||||
];
|
||||
if (options.bitPack && canPack6(cropped)) candidates.push({ b: [minX, minY, w, h], e: 'bp6', v: pack6Text(cropped), n: cropped.length });
|
||||
return chooseSmallest(candidates);
|
||||
}
|
||||
|
||||
function expandPlane(packed, size, emptyChar = '.') {
|
||||
const total = Math.max(1, Number(size) || 1) ** 2;
|
||||
function expandPlane(packed, width, emptyChar = '.', baseEncoded = null, height = width) {
|
||||
const w0 = Math.max(1, Number(width) || 1);
|
||||
const h0 = Math.max(1, Number(height) || w0);
|
||||
const total = w0 * h0;
|
||||
const out = Array(total).fill(emptyChar);
|
||||
if (baseEncoded) {
|
||||
const base = normalizeEncodedPlane(baseEncoded, w0, emptyChar, h0);
|
||||
for (let i = 0; i < Math.min(out.length, base.length); i++) out[i] = base[i] || emptyChar;
|
||||
}
|
||||
if (!packed || !packed.b) return out.join('');
|
||||
const [x0, y0, w, h] = packed.b.map((v) => Math.max(0, Number(v) || 0));
|
||||
const value = packed.e === 'rle' ? rleDecode(packed.v) : String(packed.v || '');
|
||||
let value = '';
|
||||
if (packed.e === 'rle') value = rleDecode(packed.v);
|
||||
else if (packed.e === 'bp6') value = unpack6Text(packed.v, packed.n || (w * h));
|
||||
else value = String(packed.v || '');
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const src = y * w + x;
|
||||
const dx = x0 + x;
|
||||
const dy = y0 + y;
|
||||
if (dx >= 0 && dy >= 0 && dx < size && dy < size && src < value.length) {
|
||||
out[dy * size + dx] = value[src] || emptyChar;
|
||||
}
|
||||
if (dx >= 0 && dy >= 0 && dx < w0 && dy < h0 && src < value.length) out[dy * w0 + dx] = value[src] || emptyChar;
|
||||
}
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
function packAsset(asset) {
|
||||
const size = Math.max(1, Number(asset.size) || 16);
|
||||
function planeForAsset(asset) {
|
||||
const w = assetWidth(asset);
|
||||
const h = assetHeight(asset);
|
||||
return normalizeEncodedPlane(asset?.faces?.right || asset?.pixels || '', w, '.', h);
|
||||
}
|
||||
|
||||
function selectPixelPlanePack(asset, assetMap) {
|
||||
const w = assetWidth(asset);
|
||||
const h = assetHeight(asset);
|
||||
const right = planeForAsset(asset);
|
||||
return cropPlane(right, w, '.', { bitPack: true }, h);
|
||||
}
|
||||
|
||||
function packAsset(asset, assetMap = null) {
|
||||
const width = assetWidth(asset);
|
||||
const height = assetHeight(asset);
|
||||
const size = Math.max(width, height, Math.max(1, Number(asset.size) || 16));
|
||||
const category = asset.category === 'dynamic' ? 'dynamic' : 'static';
|
||||
const right = normalizeEncodedPlane(asset.faces?.right || asset.pixels || '', size, '.');
|
||||
const depth = asset.meta?.depthPixels ? normalizeEncodedPlane(asset.meta.depthPixels, size, '.') : '';
|
||||
const depth = asset.meta?.depthPixels ? normalizeEncodedPlane(asset.meta.depthPixels, width, '.', height) : '';
|
||||
const lights = Array.isArray(asset.meta?.lightPixels)
|
||||
? asset.meta.lightPixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || asset.meta?.lightColor || '']).filter((p) => p[2])
|
||||
: [];
|
||||
const particles = Array.isArray(asset.meta?.particlePixels)
|
||||
? asset.meta.particlePixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || '']).filter((p) => p[2])
|
||||
? asset.meta.particlePixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || '', p.dir || 'up']).filter((p) => p[2])
|
||||
: [];
|
||||
const meta = {};
|
||||
if (depth && /[1\-]/.test(depth)) meta.d = cropPlane(depth, size, '.');
|
||||
if (depth && /[1\-]/.test(depth)) meta.d = cropPlane(depth, width, '.', {}, height);
|
||||
if (lights.length) meta.l = lights;
|
||||
if (particles.length) meta.pt = particles;
|
||||
if (asset.meta?.lightColor) meta.lc = asset.meta.lightColor;
|
||||
if (asset.meta?.door) meta.dr = [Number(asset.meta.door.x) || 0, Number(asset.meta.door.y) || 0];
|
||||
if (asset.meta?.particleConfig?.enabled && !particles.length) {
|
||||
const pc = asset.meta.particleConfig;
|
||||
meta.pc = [pc.c || '', pc.dir || 'up', null];
|
||||
}
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
|
|
@ -132,36 +296,47 @@
|
|||
c: category,
|
||||
t: asset.subtype || (category === 'dynamic' ? 'human' : 'other'),
|
||||
s: size,
|
||||
p: cropPlane(right, size, '.'),
|
||||
w: width !== size ? width : undefined,
|
||||
ht: height !== size ? height : undefined,
|
||||
p: selectPixelPlanePack(asset, assetMap),
|
||||
f: category === 'dynamic' ? { l: 'mirror' } : null,
|
||||
pa: asset.parentAssetId || null,
|
||||
oa: asset.originalAssetId || null,
|
||||
ca: asset.createdAt || Date.now(),
|
||||
ua: asset.updatedAt || asset.createdAt || Date.now(),
|
||||
au: asset.author || 'Local Artist',
|
||||
ow: asset.ownerAccountId || null,
|
||||
v: Number(asset.version) || 1,
|
||||
m: Object.keys(meta).length ? meta : null
|
||||
};
|
||||
}
|
||||
|
||||
function unpackAsset(packed) {
|
||||
function unpackAsset(packed, assetById = null) {
|
||||
if (!packed || !packed.id) return null;
|
||||
const size = Math.max(1, Number(packed.s || packed.size) || 16);
|
||||
const width = Math.max(1, Number(packed.w || packed.width || size) || size);
|
||||
const height = Math.max(1, Number(packed.ht || packed.height || size) || size);
|
||||
const category = packed.c === 'dynamic' || packed.category === 'dynamic' ? 'dynamic' : 'static';
|
||||
const pixels = expandPlane(packed.p, size, '.');
|
||||
const pixels = expandPlane(packed.p, width, '.', null, height);
|
||||
if (pixels == null) return null;
|
||||
const metaPacked = packed.m || {};
|
||||
const lightPixels = Array.isArray(metaPacked.l)
|
||||
? metaPacked.l.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || metaPacked.lc || 'a' }))
|
||||
: [];
|
||||
const particlePixels = Array.isArray(metaPacked.pt)
|
||||
? metaPacked.pt.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || 'a' }))
|
||||
? metaPacked.pt.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || 'a', dir: p[3] || 'up' }))
|
||||
: [];
|
||||
const particleConfig = Array.isArray(metaPacked.pc)
|
||||
? { enabled: true, c: metaPacked.pc[0] || 'a', dir: metaPacked.pc[1] || 'up', range: Array.isArray(metaPacked.pc[2]) ? { x: metaPacked.pc[2][0] || 0, y: metaPacked.pc[2][1] || 0, w: metaPacked.pc[2][2] || 0, h: metaPacked.pc[2][3] || 0 } : null }
|
||||
: null;
|
||||
const meta = {
|
||||
hasLight: lightPixels.length > 0,
|
||||
lightPixels,
|
||||
lightColor: lightPixels.length > 0 ? (metaPacked.lc || lightPixels[0]?.c || null) : null,
|
||||
hasParticles: particlePixels.length > 0,
|
||||
hasParticles: Boolean(particleConfig) || particlePixels.length > 0,
|
||||
particlePixels,
|
||||
depthPixels: metaPacked.d ? expandPlane(metaPacked.d, size, '.') : null,
|
||||
particleConfig,
|
||||
depthPixels: metaPacked.d ? expandPlane(metaPacked.d, width, '.', null, height) : null,
|
||||
door: Array.isArray(metaPacked.dr) ? { x: Number(metaPacked.dr[0]) || 0, y: Number(metaPacked.dr[1]) || 0 } : null
|
||||
};
|
||||
return {
|
||||
|
|
@ -170,6 +345,8 @@
|
|||
category,
|
||||
subtype: packed.t || (category === 'dynamic' ? 'human' : 'other'),
|
||||
size,
|
||||
width,
|
||||
height,
|
||||
pixels,
|
||||
faces: category === 'dynamic' ? { right: pixels, left: 'mirror' } : null,
|
||||
parentAssetId: packed.pa || null,
|
||||
|
|
@ -177,43 +354,57 @@
|
|||
createdAt: packed.ca || Date.now(),
|
||||
updatedAt: packed.ua || packed.ca || Date.now(),
|
||||
author: packed.au || 'Local Artist',
|
||||
ownerAccountId: packed.ow || packed.ownerAccountId || '',
|
||||
version: Number(packed.v || packed.version) || 1,
|
||||
meta,
|
||||
contentHash: packed.h || null
|
||||
};
|
||||
}
|
||||
|
||||
function unpackAssets(rows) {
|
||||
return (Array.isArray(rows) ? rows : []).map((row) => unpackAsset(row)).filter(Boolean);
|
||||
}
|
||||
|
||||
function packPlacement(item) {
|
||||
const meta = {};
|
||||
if (item.publishedAt) meta.pu = item.publishedAt;
|
||||
if (item.status && item.status !== 'active') meta.st = item.status;
|
||||
if (item.ownerAccountId) meta.ow = item.ownerAccountId;
|
||||
return [item.id, item.assetId, Number(item.x) || 0, Number(item.y) || 0, item.placedAt || Date.now(), Number(item.version) || 1, Object.keys(meta).length ? meta : null];
|
||||
}
|
||||
|
||||
function unpackPlacement(row) {
|
||||
if (!Array.isArray(row)) return row;
|
||||
const meta = row[6] || {};
|
||||
return { id: row[0], assetId: row[1], x: row[2], y: row[3], placedAt: row[4], version: row[5] || 1, publishedAt: meta.pu || row[4], status: meta.st || 'active' };
|
||||
return { id: row[0], assetId: row[1], x: row[2], y: row[3], placedAt: row[4], version: row[5] || 1, publishedAt: meta.pu || row[4], status: meta.st || 'active', ownerAccountId: meta.ow || '' };
|
||||
}
|
||||
|
||||
function packDynamic(item) {
|
||||
const meta = {};
|
||||
if (item.publishedAt) meta.pu = item.publishedAt;
|
||||
if (item.status && item.status !== 'active') meta.st = item.status;
|
||||
if (item.ownerAccountId) meta.ow = item.ownerAccountId;
|
||||
return [item.id, item.assetId, Number(item.homeX) || 0, Number(item.homeY) || 0, item.createdAt || Date.now(), Number(item.version) || 1, Object.keys(meta).length ? meta : null];
|
||||
}
|
||||
|
||||
function unpackDynamic(row) {
|
||||
if (!Array.isArray(row)) return row;
|
||||
const meta = row[6] || {};
|
||||
return { id: row[0], assetId: row[1], homeX: row[2], homeY: row[3], createdAt: row[4], version: row[5] || 1, publishedAt: meta.pu || row[4], status: meta.st || 'active' };
|
||||
return { id: row[0], assetId: row[1], homeX: row[2], homeY: row[3], createdAt: row[4], version: row[5] || 1, publishedAt: meta.pu || row[4], status: meta.st || 'active', ownerAccountId: meta.ow || '' };
|
||||
}
|
||||
|
||||
function assetMapFor(assets) {
|
||||
return new Map((assets || []).map((asset) => [asset.id, asset]));
|
||||
}
|
||||
|
||||
function compactState(state) {
|
||||
const assets = Array.isArray(state.assets) ? state.assets : [];
|
||||
const map = assetMapFor(assets);
|
||||
return {
|
||||
schema: 3,
|
||||
schema: 4,
|
||||
format: FORMAT,
|
||||
authorName: state.authorName || 'Local Artist',
|
||||
assets: Array.isArray(state.assets) ? state.assets.map(packAsset) : [],
|
||||
assets: assets.map((asset) => packAsset(asset, map)),
|
||||
placed: Array.isArray(state.placed) ? state.placed.map(packPlacement) : [],
|
||||
dynamicSummons: Array.isArray(state.dynamicSummons) ? state.dynamicSummons.map(packDynamic) : [],
|
||||
objectVotes: state.objectVotes || {},
|
||||
|
|
@ -226,16 +417,20 @@
|
|||
account: state.account || null,
|
||||
publishLog: Array.isArray(state.publishLog) ? state.publishLog.slice(-300) : [],
|
||||
eventLog: Array.isArray(state.eventLog) ? state.eventLog.slice(-EVENT_LOG_LIMIT) : [],
|
||||
sync: state.sync || { lastEventId: null }
|
||||
sync: state.sync || { lastEventId: null },
|
||||
worldMode: state.worldMode === 'shared' ? 'shared' : 'local',
|
||||
serverSync: state.serverSync || { lastServerEventId: null, pendingCommands: [] },
|
||||
tombstones: state.tombstones || { assets: {}, objects: {} },
|
||||
deletedSeedAssetNames: Array.isArray(state.deletedSeedAssetNames) ? state.deletedSeedAssetNames : []
|
||||
};
|
||||
}
|
||||
|
||||
function expandState(input) {
|
||||
if (!isCompactState(input)) return input;
|
||||
return {
|
||||
schema: 3,
|
||||
schema: input.schema || 4,
|
||||
authorName: input.authorName || 'Local Artist',
|
||||
assets: input.assets.map(unpackAsset).filter(Boolean),
|
||||
assets: unpackAssets(input.assets),
|
||||
placed: (input.placed || []).map(unpackPlacement),
|
||||
dynamicSummons: (input.dynamicSummons || []).map(unpackDynamic),
|
||||
objectVotes: input.objectVotes || {},
|
||||
|
|
@ -248,12 +443,16 @@
|
|||
account: input.account || null,
|
||||
publishLog: Array.isArray(input.publishLog) ? input.publishLog.slice(-300) : [],
|
||||
eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-EVENT_LOG_LIMIT) : [],
|
||||
sync: input.sync || { lastEventId: null }
|
||||
sync: input.sync || { lastEventId: null },
|
||||
worldMode: input.worldMode === 'shared' ? 'shared' : 'local',
|
||||
serverSync: input.serverSync || { lastServerEventId: null, pendingCommands: [] },
|
||||
tombstones: input.tombstones || { assets: {}, objects: {} },
|
||||
deletedSeedAssetNames: Array.isArray(input.deletedSeedAssetNames) ? input.deletedSeedAssetNames : []
|
||||
};
|
||||
}
|
||||
|
||||
function compactSizeReport(state) {
|
||||
const full = JSON.stringify({ ...state, schema: 3 });
|
||||
const full = JSON.stringify({ ...state, schema: 4 });
|
||||
const compact = JSON.stringify(compactState(state));
|
||||
return {
|
||||
fullBytes: full.length,
|
||||
|
|
@ -266,12 +465,12 @@
|
|||
}
|
||||
|
||||
function assetManifest(state) {
|
||||
return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, s: asset.size, c: asset.category, t: asset.subtype }));
|
||||
return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, s: asset.size, w: asset.width || null, ht: asset.height || null, c: asset.category, t: asset.subtype, pa: asset.parentAssetId || null, oa: asset.originalAssetId || null }));
|
||||
}
|
||||
|
||||
function makeSnapshot(state, worldId = 'local-main') {
|
||||
return {
|
||||
schema: 3,
|
||||
schema: 4,
|
||||
format: SNAPSHOT_FORMAT,
|
||||
worldId,
|
||||
createdAt: Date.now(),
|
||||
|
|
@ -284,13 +483,14 @@
|
|||
|
||||
function makeAssetBundle(state, assetIds) {
|
||||
const wanted = new Set(assetIds || []);
|
||||
const assets = (state.assets || []).filter((asset) => !wanted.size || wanted.has(asset.id)).map(packAsset);
|
||||
return { schema: 3, format: ASSET_BUNDLE_FORMAT, createdAt: Date.now(), assets };
|
||||
const assets = (state.assets || []).filter((asset) => !wanted.size || wanted.has(asset.id));
|
||||
const map = assetMapFor(assets);
|
||||
return { schema: 4, format: ASSET_BUNDLE_FORMAT, createdAt: Date.now(), assets: assets.map((asset) => packAsset(asset, map)) };
|
||||
}
|
||||
|
||||
function unpackAssetBundle(bundle) {
|
||||
if (!isAssetBundle(bundle)) return [];
|
||||
return bundle.assets.map(unpackAsset).filter(Boolean);
|
||||
return unpackAssets(bundle.assets);
|
||||
}
|
||||
|
||||
function findMissingAssetIds(snapshot, knownAssetIds) {
|
||||
|
|
@ -306,6 +506,10 @@
|
|||
return makeEvent('asset.upsert', { asset: packAsset(asset) });
|
||||
}
|
||||
|
||||
function createAssetDeleteEvent(assetId) {
|
||||
return makeEvent('asset.delete', { assetId });
|
||||
}
|
||||
|
||||
function createObjectUpsertEvent(kind, object) {
|
||||
return makeEvent('object.upsert', { kind, object: kind === 'dynamic' ? packDynamic(object) : packPlacement(object) });
|
||||
}
|
||||
|
|
@ -314,15 +518,34 @@
|
|||
return makeEvent('object.delete', { kind, objectId });
|
||||
}
|
||||
|
||||
function deleteAssetOnly(state, assetId) {
|
||||
if (!state || !assetId) return state;
|
||||
const removedObjectIds = new Set();
|
||||
for (const item of state.placed || []) if (item.assetId === assetId) removedObjectIds.add(item.id);
|
||||
for (const item of state.dynamicSummons || []) if (item.assetId === assetId) removedObjectIds.add(item.id);
|
||||
state.assets = (state.assets || []).filter((asset) => asset.id !== assetId);
|
||||
state.placed = (state.placed || []).filter((item) => item.assetId !== assetId);
|
||||
state.dynamicSummons = (state.dynamicSummons || []).filter((item) => item.assetId !== assetId);
|
||||
delete state.assetVotes?.[assetId];
|
||||
delete state.hiddenAssets?.[assetId];
|
||||
for (const id of removedObjectIds) delete state.objectVotes?.[id], delete state.hiddenObjects?.[id];
|
||||
state.moderationReports = (state.moderationReports || []).filter((report) => !removedObjectIds.has(report.objectId));
|
||||
return state;
|
||||
}
|
||||
|
||||
function applyEvent(state, event) {
|
||||
if (!state || !event) return state;
|
||||
if (event.type === 'asset.upsert' && event.asset) {
|
||||
const asset = unpackAsset(event.asset);
|
||||
const byId = assetMapFor(state.assets || []);
|
||||
const asset = unpackAsset(event.asset, byId);
|
||||
if (!asset) return state;
|
||||
const index = (state.assets || []).findIndex((a) => a.id === asset.id);
|
||||
if (index >= 0) state.assets[index] = asset;
|
||||
else (state.assets ||= []).unshift(asset);
|
||||
}
|
||||
if (event.type === 'asset.delete' && event.assetId) {
|
||||
deleteAssetOnly(state, event.assetId);
|
||||
}
|
||||
if (event.type === 'object.upsert') {
|
||||
if (event.kind === 'dynamic') {
|
||||
const object = unpackDynamic(event.object);
|
||||
|
|
@ -381,7 +604,7 @@
|
|||
request.onerror = () => resolve(null);
|
||||
})));
|
||||
db.close();
|
||||
return rows.filter(Boolean).map(unpackAsset).filter(Boolean);
|
||||
return unpackAssets(rows.filter(Boolean));
|
||||
}
|
||||
|
||||
async function cacheSnapshot(snapshot) {
|
||||
|
|
@ -398,17 +621,28 @@
|
|||
|
||||
root.Phase2Sync = {
|
||||
FORMAT,
|
||||
FORMAT_V1,
|
||||
SNAPSHOT_FORMAT,
|
||||
ASSET_BUNDLE_FORMAT,
|
||||
ASSET_BUNDLE_FORMAT_V1,
|
||||
EVENT_LOG_LIMIT,
|
||||
isCompactState,
|
||||
isAssetBundle,
|
||||
rleEncode,
|
||||
rleDecode,
|
||||
pack6Text,
|
||||
unpack6Text,
|
||||
packBitMask,
|
||||
unpackBitMask,
|
||||
cropPlane,
|
||||
expandPlane,
|
||||
packAsset,
|
||||
unpackAsset,
|
||||
unpackAssets,
|
||||
packPlacement,
|
||||
unpackPlacement,
|
||||
packDynamic,
|
||||
unpackDynamic,
|
||||
compactState,
|
||||
expandState,
|
||||
compactSizeReport,
|
||||
|
|
@ -419,8 +653,10 @@
|
|||
findMissingAssetIds,
|
||||
makeEvent,
|
||||
createAssetUpsertEvent,
|
||||
createAssetDeleteEvent,
|
||||
createObjectUpsertEvent,
|
||||
createObjectDeleteEvent,
|
||||
deleteAssetOnly,
|
||||
applyEvent,
|
||||
cacheAssets,
|
||||
readCachedAssets,
|
||||
|
|
|
|||
|
|
@ -1,402 +0,0 @@
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
const root = window.PixelIslandModules ||= {};
|
||||
const FORMAT = 'pixel-island-phase2-compact-v1';
|
||||
const SNAPSHOT_FORMAT = 'pixel-island-phase2-snapshot-v1';
|
||||
const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v1';
|
||||
const EVENT_LOG_LIMIT = 300;
|
||||
const DB_NAME = 'pixel-island-phase2-cache';
|
||||
const DB_VERSION = 1;
|
||||
const ASSET_STORE = 'assets';
|
||||
const SNAPSHOT_STORE = 'snapshots';
|
||||
|
||||
function isCompactState(value) {
|
||||
return Boolean(value && value.format === FORMAT && Array.isArray(value.assets));
|
||||
}
|
||||
|
||||
function isAssetBundle(value) {
|
||||
return Boolean(value && value.format === ASSET_BUNDLE_FORMAT && Array.isArray(value.assets));
|
||||
}
|
||||
|
||||
function rleEncode(input) {
|
||||
const text = String(input || '');
|
||||
if (!text) return '';
|
||||
let out = '';
|
||||
let last = text[0];
|
||||
let count = 1;
|
||||
for (let i = 1; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === last) count++;
|
||||
else {
|
||||
out += `${count}:${last}`;
|
||||
last = ch;
|
||||
count = 1;
|
||||
}
|
||||
}
|
||||
out += `${count}:${last}`;
|
||||
return out;
|
||||
}
|
||||
|
||||
function rleDecode(input) {
|
||||
const text = String(input || '');
|
||||
if (!text) return '';
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
let digits = '';
|
||||
while (i < text.length && text[i] >= '0' && text[i] <= '9') digits += text[i++];
|
||||
if (text[i] !== ':') break;
|
||||
i++;
|
||||
const ch = text[i++] || '';
|
||||
const count = Math.max(0, Number(digits) || 0);
|
||||
out += ch.repeat(count);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeEncodedPlane(value, size, emptyChar = '.') {
|
||||
const total = Math.max(1, Number(size) || 1) ** 2;
|
||||
const source = typeof value === 'string' ? value : Array.isArray(value) ? value.map((v) => v || emptyChar).join('') : '';
|
||||
return (source + emptyChar.repeat(total)).slice(0, total);
|
||||
}
|
||||
|
||||
function cropPlane(encoded, size, emptyChar = '.') {
|
||||
const text = normalizeEncodedPlane(encoded, size, emptyChar);
|
||||
let minX = size;
|
||||
let minY = size;
|
||||
let maxX = -1;
|
||||
let maxY = -1;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
if (text[y * size + x] !== emptyChar) {
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxX < 0) return { b: null, e: 'raw', v: '' };
|
||||
const w = maxX - minX + 1;
|
||||
const h = maxY - minY + 1;
|
||||
let cropped = '';
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
cropped += text.slice(y * size + minX, y * size + minX + w);
|
||||
}
|
||||
const rle = rleEncode(cropped);
|
||||
return rle.length < cropped.length ? { b: [minX, minY, w, h], e: 'rle', v: rle } : { b: [minX, minY, w, h], e: 'raw', v: cropped };
|
||||
}
|
||||
|
||||
function expandPlane(packed, size, emptyChar = '.') {
|
||||
const total = Math.max(1, Number(size) || 1) ** 2;
|
||||
const out = Array(total).fill(emptyChar);
|
||||
if (!packed || !packed.b) return out.join('');
|
||||
const [x0, y0, w, h] = packed.b.map((v) => Math.max(0, Number(v) || 0));
|
||||
const value = packed.e === 'rle' ? rleDecode(packed.v) : String(packed.v || '');
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const src = y * w + x;
|
||||
const dx = x0 + x;
|
||||
const dy = y0 + y;
|
||||
if (dx >= 0 && dy >= 0 && dx < size && dy < size && src < value.length) {
|
||||
out[dy * size + dx] = value[src] || emptyChar;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
function packAsset(asset) {
|
||||
const size = Math.max(1, Number(asset.size) || 16);
|
||||
const category = asset.category === 'dynamic' ? 'dynamic' : 'static';
|
||||
const right = normalizeEncodedPlane(asset.faces?.right || asset.pixels || '', size, '.');
|
||||
const depth = asset.meta?.depthPixels ? normalizeEncodedPlane(asset.meta.depthPixels, size, '.') : '';
|
||||
const lights = Array.isArray(asset.meta?.lightPixels)
|
||||
? asset.meta.lightPixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || asset.meta?.lightColor || '']).filter((p) => p[2])
|
||||
: [];
|
||||
const meta = {};
|
||||
if (depth && /1/.test(depth)) meta.d = cropPlane(depth, size, '.');
|
||||
if (lights.length) meta.l = lights;
|
||||
if (asset.meta?.lightColor) meta.lc = asset.meta.lightColor;
|
||||
if (asset.meta?.door) meta.dr = [Number(asset.meta.door.x) || 0, Number(asset.meta.door.y) || 0];
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
h: asset.contentHash || asset.hash || null,
|
||||
n: asset.name || 'Untitled',
|
||||
c: category,
|
||||
t: asset.subtype || (category === 'dynamic' ? 'human' : 'other'),
|
||||
s: size,
|
||||
p: cropPlane(right, size, '.'),
|
||||
f: category === 'dynamic' ? { l: 'mirror' } : null,
|
||||
pa: asset.parentAssetId || null,
|
||||
oa: asset.originalAssetId || null,
|
||||
ca: asset.createdAt || Date.now(),
|
||||
ua: asset.updatedAt || asset.createdAt || Date.now(),
|
||||
au: asset.author || 'Local Artist',
|
||||
m: Object.keys(meta).length ? meta : null
|
||||
};
|
||||
}
|
||||
|
||||
function unpackAsset(packed) {
|
||||
if (!packed || !packed.id) return null;
|
||||
const size = Math.max(1, Number(packed.s || packed.size) || 16);
|
||||
const category = packed.c === 'dynamic' || packed.category === 'dynamic' ? 'dynamic' : 'static';
|
||||
const pixels = expandPlane(packed.p, size, '.');
|
||||
const metaPacked = packed.m || {};
|
||||
const lightPixels = Array.isArray(metaPacked.l)
|
||||
? metaPacked.l.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || metaPacked.lc || 'a' }))
|
||||
: [];
|
||||
const meta = {
|
||||
hasLight: lightPixels.length > 0,
|
||||
lightPixels,
|
||||
lightColor: lightPixels.length > 0 ? (metaPacked.lc || lightPixels[0]?.c || null) : null,
|
||||
depthPixels: metaPacked.d ? expandPlane(metaPacked.d, size, '.') : null,
|
||||
door: Array.isArray(metaPacked.dr) ? { x: Number(metaPacked.dr[0]) || 0, y: Number(metaPacked.dr[1]) || 0 } : null
|
||||
};
|
||||
return {
|
||||
id: packed.id,
|
||||
name: packed.n || 'Untitled',
|
||||
category,
|
||||
subtype: packed.t || (category === 'dynamic' ? 'human' : 'other'),
|
||||
size,
|
||||
pixels,
|
||||
faces: category === 'dynamic' ? { right: pixels, left: 'mirror' } : null,
|
||||
parentAssetId: packed.pa || null,
|
||||
originalAssetId: packed.oa || null,
|
||||
createdAt: packed.ca || Date.now(),
|
||||
updatedAt: packed.ua || packed.ca || Date.now(),
|
||||
author: packed.au || 'Local Artist',
|
||||
meta,
|
||||
contentHash: packed.h || null
|
||||
};
|
||||
}
|
||||
|
||||
function packPlacement(item) {
|
||||
return [item.id, item.assetId, Number(item.x) || 0, Number(item.y) || 0, item.placedAt || Date.now(), Number(item.version) || 1];
|
||||
}
|
||||
|
||||
function unpackPlacement(row) {
|
||||
if (!Array.isArray(row)) return row;
|
||||
return { id: row[0], assetId: row[1], x: row[2], y: row[3], placedAt: row[4], version: row[5] || 1 };
|
||||
}
|
||||
|
||||
function packDynamic(item) {
|
||||
return [item.id, item.assetId, Number(item.homeX) || 0, Number(item.homeY) || 0, item.createdAt || Date.now(), Number(item.version) || 1];
|
||||
}
|
||||
|
||||
function unpackDynamic(row) {
|
||||
if (!Array.isArray(row)) return row;
|
||||
return { id: row[0], assetId: row[1], homeX: row[2], homeY: row[3], createdAt: row[4], version: row[5] || 1 };
|
||||
}
|
||||
|
||||
function compactState(state) {
|
||||
return {
|
||||
schema: 3,
|
||||
format: FORMAT,
|
||||
authorName: state.authorName || 'Local Artist',
|
||||
assets: Array.isArray(state.assets) ? state.assets.map(packAsset) : [],
|
||||
placed: Array.isArray(state.placed) ? state.placed.map(packPlacement) : [],
|
||||
dynamicSummons: Array.isArray(state.dynamicSummons) ? state.dynamicSummons.map(packDynamic) : [],
|
||||
objectVotes: state.objectVotes || {},
|
||||
assetVotes: state.assetVotes || {},
|
||||
hiddenAssets: state.hiddenAssets || {},
|
||||
hiddenObjects: state.hiddenObjects || {},
|
||||
eventLog: Array.isArray(state.eventLog) ? state.eventLog.slice(-EVENT_LOG_LIMIT) : [],
|
||||
sync: state.sync || { lastEventId: null }
|
||||
};
|
||||
}
|
||||
|
||||
function expandState(input) {
|
||||
if (!isCompactState(input)) return input;
|
||||
return {
|
||||
schema: 3,
|
||||
authorName: input.authorName || 'Local Artist',
|
||||
assets: input.assets.map(unpackAsset).filter(Boolean),
|
||||
placed: (input.placed || []).map(unpackPlacement),
|
||||
dynamicSummons: (input.dynamicSummons || []).map(unpackDynamic),
|
||||
objectVotes: input.objectVotes || {},
|
||||
assetVotes: input.assetVotes || {},
|
||||
hiddenAssets: input.hiddenAssets || {},
|
||||
hiddenObjects: input.hiddenObjects || {},
|
||||
eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-EVENT_LOG_LIMIT) : [],
|
||||
sync: input.sync || { lastEventId: null }
|
||||
};
|
||||
}
|
||||
|
||||
function compactSizeReport(state) {
|
||||
const full = JSON.stringify({ ...state, schema: 3 });
|
||||
const compact = JSON.stringify(compactState(state));
|
||||
return {
|
||||
fullBytes: full.length,
|
||||
compactBytes: compact.length,
|
||||
savedBytes: Math.max(0, full.length - compact.length),
|
||||
savedPercent: full.length ? Math.round((1 - compact.length / full.length) * 1000) / 10 : 0,
|
||||
assets: state.assets?.length || 0,
|
||||
objects: (state.placed?.length || 0) + (state.dynamicSummons?.length || 0)
|
||||
};
|
||||
}
|
||||
|
||||
function assetManifest(state) {
|
||||
return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, s: asset.size, c: asset.category, t: asset.subtype }));
|
||||
}
|
||||
|
||||
function makeSnapshot(state, worldId = 'local-main') {
|
||||
return {
|
||||
schema: 3,
|
||||
format: SNAPSHOT_FORMAT,
|
||||
worldId,
|
||||
createdAt: Date.now(),
|
||||
manifest: assetManifest(state),
|
||||
placed: (state.placed || []).map(packPlacement),
|
||||
dynamicSummons: (state.dynamicSummons || []).map(packDynamic),
|
||||
hiddenObjects: state.hiddenObjects || {}
|
||||
};
|
||||
}
|
||||
|
||||
function makeAssetBundle(state, assetIds) {
|
||||
const wanted = new Set(assetIds || []);
|
||||
const assets = (state.assets || []).filter((asset) => !wanted.size || wanted.has(asset.id)).map(packAsset);
|
||||
return { schema: 3, format: ASSET_BUNDLE_FORMAT, createdAt: Date.now(), assets };
|
||||
}
|
||||
|
||||
function unpackAssetBundle(bundle) {
|
||||
if (!isAssetBundle(bundle)) return [];
|
||||
return bundle.assets.map(unpackAsset).filter(Boolean);
|
||||
}
|
||||
|
||||
function findMissingAssetIds(snapshot, knownAssetIds) {
|
||||
const known = new Set(knownAssetIds || []);
|
||||
return (snapshot?.manifest || []).map((asset) => asset.id).filter((id) => id && !known.has(id));
|
||||
}
|
||||
|
||||
function makeEvent(type, payload = {}) {
|
||||
return { id: `ev_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, type, at: Date.now(), ...payload };
|
||||
}
|
||||
|
||||
function createAssetUpsertEvent(asset) {
|
||||
return makeEvent('asset.upsert', { asset: packAsset(asset) });
|
||||
}
|
||||
|
||||
function createObjectUpsertEvent(kind, object) {
|
||||
return makeEvent('object.upsert', { kind, object: kind === 'dynamic' ? packDynamic(object) : packPlacement(object) });
|
||||
}
|
||||
|
||||
function createObjectDeleteEvent(kind, objectId) {
|
||||
return makeEvent('object.delete', { kind, objectId });
|
||||
}
|
||||
|
||||
function applyEvent(state, event) {
|
||||
if (!state || !event) return state;
|
||||
if (event.type === 'asset.upsert' && event.asset) {
|
||||
const asset = unpackAsset(event.asset);
|
||||
if (!asset) return state;
|
||||
const index = (state.assets || []).findIndex((a) => a.id === asset.id);
|
||||
if (index >= 0) state.assets[index] = asset;
|
||||
else (state.assets ||= []).unshift(asset);
|
||||
}
|
||||
if (event.type === 'object.upsert') {
|
||||
if (event.kind === 'dynamic') {
|
||||
const object = unpackDynamic(event.object);
|
||||
const list = state.dynamicSummons ||= [];
|
||||
const index = list.findIndex((item) => item.id === object.id);
|
||||
if (index >= 0) list[index] = object;
|
||||
else list.push(object);
|
||||
} else {
|
||||
const object = unpackPlacement(event.object);
|
||||
const list = state.placed ||= [];
|
||||
const index = list.findIndex((item) => item.id === object.id);
|
||||
if (index >= 0) list[index] = object;
|
||||
else list.push(object);
|
||||
}
|
||||
}
|
||||
if (event.type === 'object.delete') {
|
||||
const key = event.kind === 'dynamic' ? 'dynamicSummons' : 'placed';
|
||||
state[key] = (state[key] || []).filter((item) => item.id !== event.objectId);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function openDb() {
|
||||
if (!('indexedDB' in window)) return Promise.reject(new Error('IndexedDB is not available.'));
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(ASSET_STORE)) db.createObjectStore(ASSET_STORE, { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains(SNAPSHOT_STORE)) db.createObjectStore(SNAPSHOT_STORE, { keyPath: 'worldId' });
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function cacheAssets(assets) {
|
||||
if (!Array.isArray(assets) || !assets.length) return;
|
||||
const db = await openDb();
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(ASSET_STORE, 'readwrite');
|
||||
for (const asset of assets) tx.objectStore(ASSET_STORE).put(packAsset(asset));
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function readCachedAssets(assetIds) {
|
||||
const ids = Array.from(assetIds || []);
|
||||
if (!ids.length) return [];
|
||||
const db = await openDb();
|
||||
const rows = await Promise.all(ids.map((id) => new Promise((resolve) => {
|
||||
const request = db.transaction(ASSET_STORE, 'readonly').objectStore(ASSET_STORE).get(id);
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = () => resolve(null);
|
||||
})));
|
||||
db.close();
|
||||
return rows.filter(Boolean).map(unpackAsset).filter(Boolean);
|
||||
}
|
||||
|
||||
async function cacheSnapshot(snapshot) {
|
||||
if (!snapshot?.worldId) return;
|
||||
const db = await openDb();
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(SNAPSHOT_STORE, 'readwrite');
|
||||
tx.objectStore(SNAPSHOT_STORE).put(snapshot);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
db.close();
|
||||
}
|
||||
|
||||
root.Phase2Sync = {
|
||||
FORMAT,
|
||||
SNAPSHOT_FORMAT,
|
||||
ASSET_BUNDLE_FORMAT,
|
||||
EVENT_LOG_LIMIT,
|
||||
isCompactState,
|
||||
isAssetBundle,
|
||||
rleEncode,
|
||||
rleDecode,
|
||||
cropPlane,
|
||||
expandPlane,
|
||||
packAsset,
|
||||
unpackAsset,
|
||||
compactState,
|
||||
expandState,
|
||||
compactSizeReport,
|
||||
assetManifest,
|
||||
makeSnapshot,
|
||||
makeAssetBundle,
|
||||
unpackAssetBundle,
|
||||
findMissingAssetIds,
|
||||
makeEvent,
|
||||
createAssetUpsertEvent,
|
||||
createObjectUpsertEvent,
|
||||
createObjectDeleteEvent,
|
||||
applyEvent,
|
||||
cacheAssets,
|
||||
readCachedAssets,
|
||||
cacheSnapshot
|
||||
};
|
||||
})();
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
const root = window.PixelIslandModules ||= {};
|
||||
|
||||
root.RenderPipeline = {
|
||||
run(stages, context) {
|
||||
for (const stage of stages) stage(context);
|
||||
}
|
||||
};
|
||||
})();
|
||||
76
js/rotation-policy.js
Normal file
76
js/rotation-policy.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
const root = window.PixelIslandModules ||= {};
|
||||
const utils = root.CoreUtils || {};
|
||||
const fnv1a = utils.fnv1a || ((value) => {
|
||||
let hash = 0x811c9dc5;
|
||||
for (const ch of String(value)) {
|
||||
hash ^= ch.charCodeAt(0);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, '0');
|
||||
});
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
defaultDisplayLimit: 250,
|
||||
newArrivalSlots: 150,
|
||||
revivalSlots: 100,
|
||||
publishLimitFirstDay: 5,
|
||||
publishLimitTrusted: 10,
|
||||
upvoteDelaySlots: 20,
|
||||
downvoteAdvanceSlots: 25,
|
||||
upvoteRankCap: 50
|
||||
};
|
||||
|
||||
function objectPublicAt(object) {
|
||||
return Number(object?.publishedAt || object?.placedAt || object?.createdAt || Date.now());
|
||||
}
|
||||
|
||||
function entry(kind, object, baseIndex, votes, config = DEFAULT_CONFIG) {
|
||||
const rawUp = Number(votes?.up) || 0;
|
||||
const up = Math.min(rawUp, config.upvoteRankCap);
|
||||
const down = Number(votes?.down) || 0;
|
||||
return {
|
||||
kind,
|
||||
object,
|
||||
id: object?.id,
|
||||
assetId: object?.assetId,
|
||||
publicAt: objectPublicAt(object),
|
||||
baseIndex,
|
||||
up,
|
||||
rawUp,
|
||||
down,
|
||||
effectiveSlot: baseIndex + up * config.upvoteDelaySlots - down * config.downvoteAdvanceSlots
|
||||
};
|
||||
}
|
||||
|
||||
function seededScore(id, salt, rotationAt = Date.now()) {
|
||||
const day = Math.floor((rotationAt || Date.now()) / (24 * 60 * 60 * 1000));
|
||||
return parseInt(fnv1a(`${id}|${salt}|${day}`).slice(0, 8), 16) / 0xffffffff;
|
||||
}
|
||||
|
||||
function buckets(entries, localLimit, config = DEFAULT_CONFIG, rotationAt = Date.now()) {
|
||||
const newCap = Math.min(config.newArrivalSlots, localLimit);
|
||||
const revivalCap = Math.max(0, Math.min(config.revivalSlots, localLimit - newCap));
|
||||
const newest = entries
|
||||
.slice()
|
||||
.sort((a, b) => b.effectiveSlot - a.effectiveSlot || b.publicAt - a.publicAt || String(b.id).localeCompare(String(a.id)))
|
||||
.slice(0, newCap);
|
||||
const newestIds = new Set(newest.map((item) => item.id));
|
||||
const revival = entries
|
||||
.filter((item) => !newestIds.has(item.id))
|
||||
.sort((a, b) => seededScore(b.id, 'revival', rotationAt) - seededScore(a.id, 'revival', rotationAt) || b.up - a.up || String(b.id).localeCompare(String(a.id)))
|
||||
.slice(0, revivalCap);
|
||||
return { newest, revival, entries, visibleIds: new Set([...newest, ...revival].map((item) => item.id)) };
|
||||
}
|
||||
|
||||
function publishLimit(account, now = Date.now(), config = DEFAULT_CONFIG) {
|
||||
if (!account?.createdAt) return 0;
|
||||
return now - Number(account.createdAt) < 24 * 60 * 60 * 1000
|
||||
? config.publishLimitFirstDay
|
||||
: config.publishLimitTrusted;
|
||||
}
|
||||
|
||||
root.RotationPolicy = { DEFAULT_CONFIG, objectPublicAt, entry, buckets, seededScore, publishLimit };
|
||||
})();
|
||||
103
js/state-index.js
Normal file
103
js/state-index.js
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
const root = window.PixelIslandModules ||= {};
|
||||
|
||||
function tileKey(x, y) {
|
||||
return `${Math.round(x)},${Math.round(y)}`;
|
||||
}
|
||||
|
||||
function pushBucket(map, key, item) {
|
||||
const bucket = map.get(key);
|
||||
if (bucket) bucket.push(item);
|
||||
else map.set(key, [item]);
|
||||
}
|
||||
|
||||
function build(state) {
|
||||
const index = {
|
||||
assetById: new Map(),
|
||||
staticById: new Map(),
|
||||
dynamicById: new Map(),
|
||||
objectsById: new Map(),
|
||||
placedByTile: new Map(),
|
||||
dynamicByHomeTile: new Map(),
|
||||
objectIdsByAssetId: new Map()
|
||||
};
|
||||
|
||||
for (const asset of state.assets || []) {
|
||||
if (asset?.id) index.assetById.set(asset.id, asset);
|
||||
}
|
||||
|
||||
for (const placed of state.placed || []) {
|
||||
if (!placed?.id) continue;
|
||||
index.staticById.set(placed.id, placed);
|
||||
index.objectsById.set(placed.id, { kind: 'static', object: placed });
|
||||
pushBucket(index.placedByTile, tileKey(placed.x, placed.y), placed);
|
||||
pushBucket(index.objectIdsByAssetId, placed.assetId, placed.id);
|
||||
}
|
||||
|
||||
for (const summon of state.dynamicSummons || []) {
|
||||
if (!summon?.id) continue;
|
||||
index.dynamicById.set(summon.id, summon);
|
||||
index.objectsById.set(summon.id, { kind: 'dynamic', object: summon });
|
||||
pushBucket(index.dynamicByHomeTile, tileKey(summon.homeX, summon.homeY), summon);
|
||||
pushBucket(index.objectIdsByAssetId, summon.assetId, summon.id);
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
function reduce(state, event, handlers = {}) {
|
||||
if (!state || !event) return state;
|
||||
switch (event.type) {
|
||||
case 'asset.upsert': {
|
||||
const asset = handlers.unpackAsset ? handlers.unpackAsset(event.asset) : event.asset;
|
||||
if (!asset?.id) return state;
|
||||
const list = state.assets ||= [];
|
||||
const index = list.findIndex((item) => item.id === asset.id);
|
||||
if (index >= 0) list[index] = asset;
|
||||
else list.unshift(asset);
|
||||
break;
|
||||
}
|
||||
case 'asset.delete': {
|
||||
const assetId = event.assetId;
|
||||
if (!assetId) return state;
|
||||
const removed = new Set();
|
||||
for (const item of state.placed || []) if (item.assetId === assetId) removed.add(item.id);
|
||||
for (const item of state.dynamicSummons || []) if (item.assetId === assetId) removed.add(item.id);
|
||||
state.assets = (state.assets || []).filter((asset) => asset.id !== assetId);
|
||||
state.placed = (state.placed || []).filter((item) => item.assetId !== assetId);
|
||||
state.dynamicSummons = (state.dynamicSummons || []).filter((item) => item.assetId !== assetId);
|
||||
delete state.assetVotes?.[assetId];
|
||||
delete state.hiddenAssets?.[assetId];
|
||||
for (const id of removed) {
|
||||
delete state.objectVotes?.[id];
|
||||
delete state.hiddenObjects?.[id];
|
||||
}
|
||||
state.moderationReports = (state.moderationReports || []).filter((report) => !removed.has(report.objectId));
|
||||
break;
|
||||
}
|
||||
case 'object.upsert': {
|
||||
const object = handlers.unpackObject ? handlers.unpackObject(event.kind, event.object) : event.object;
|
||||
if (!object?.id) return state;
|
||||
const key = event.kind === 'dynamic' ? 'dynamicSummons' : 'placed';
|
||||
const list = state[key] ||= [];
|
||||
const index = list.findIndex((item) => item.id === object.id);
|
||||
if (index >= 0) list[index] = object;
|
||||
else list.push(object);
|
||||
break;
|
||||
}
|
||||
case 'object.delete': {
|
||||
const key = event.kind === 'dynamic' ? 'dynamicSummons' : 'placed';
|
||||
state[key] = (state[key] || []).filter((item) => item.id !== event.objectId);
|
||||
delete state.objectVotes?.[event.objectId];
|
||||
delete state.hiddenObjects?.[event.objectId];
|
||||
state.moderationReports = (state.moderationReports || []).filter((report) => report.objectId !== event.objectId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
root.StateIndex = { build, reduce, tileKey };
|
||||
})();
|
||||
24
js/vector.js
24
js/vector.js
|
|
@ -1,24 +0,0 @@
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
const root = window.PixelIslandModules ||= {};
|
||||
|
||||
root.Vec2 = {
|
||||
add(a, b) {
|
||||
return { x: a.x + b.x, y: a.y + b.y };
|
||||
},
|
||||
sub(a, b) {
|
||||
return { x: a.x - b.x, y: a.y - b.y };
|
||||
},
|
||||
scale(v, amount) {
|
||||
return { x: v.x * amount, y: v.y * amount };
|
||||
},
|
||||
length(v) {
|
||||
return Math.hypot(v.x, v.y);
|
||||
},
|
||||
normalize(v) {
|
||||
const length = Math.hypot(v.x, v.y) || 1;
|
||||
return { x: v.x / length, y: v.y / length };
|
||||
}
|
||||
};
|
||||
})();
|
||||
Binary file not shown.
Binary file not shown.
12
server/rotation_policy.json
Normal file
12
server/rotation_policy.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"display_limit": 250,
|
||||
"newest_slots": 150,
|
||||
"revival_slots": 100,
|
||||
"revival_sample_size": 50,
|
||||
"revival_pick_count": 20,
|
||||
"upvote_delay_slots": 20,
|
||||
"downvote_advance_slots": 25,
|
||||
"upvote_rank_cap": 50,
|
||||
"extreme_downvotes": 10,
|
||||
"extreme_margin": 8
|
||||
}
|
||||
|
|
@ -65,6 +65,33 @@ class RotationConfig:
|
|||
extreme_downvotes: int = EXTREME_DOWNVOTES
|
||||
extreme_margin: int = EXTREME_MARGIN
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.display_limit = max(0, int(self.display_limit))
|
||||
self.newest_slots = max(0, int(self.newest_slots))
|
||||
self.revival_slots = max(0, int(self.revival_slots))
|
||||
if self.newest_slots + self.revival_slots > self.display_limit:
|
||||
self.revival_slots = max(0, self.display_limit - self.newest_slots)
|
||||
self.newest_slots = min(self.newest_slots, self.display_limit)
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, values: MutableMapping[str, Any] | None) -> "RotationConfig":
|
||||
data = values or {}
|
||||
allowed = set(cls.__dataclass_fields__)
|
||||
clean = {key: int(value) for key, value in data.items() if key in allowed and value is not None}
|
||||
return cls(**clean)
|
||||
|
||||
|
||||
def load_config(path: Optional[Path], overrides: MutableMapping[str, Any] | None = None) -> RotationConfig:
|
||||
data: Dict[str, Any] = {}
|
||||
if path:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
if not isinstance(loaded, MutableMapping):
|
||||
raise ValueError("Rotation config JSON root must be an object")
|
||||
data.update(loaded)
|
||||
data.update({k: v for k, v in (overrides or {}).items() if v is not None})
|
||||
return RotationConfig.from_mapping(data)
|
||||
|
||||
|
||||
def now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
|
@ -287,9 +314,10 @@ def parse_args() -> argparse.Namespace:
|
|||
parser.add_argument("--write", action="store_true")
|
||||
parser.add_argument("--out", type=Path)
|
||||
parser.add_argument("--seed", type=int)
|
||||
parser.add_argument("--display-limit", type=int, default=DISPLAY_LIMIT)
|
||||
parser.add_argument("--newest-slots", type=int, default=NEWEST_SLOTS)
|
||||
parser.add_argument("--revival-slots", type=int, default=REVIVAL_SLOTS)
|
||||
parser.add_argument("--config", type=Path, help="Optional rotation policy JSON file.")
|
||||
parser.add_argument("--display-limit", type=int)
|
||||
parser.add_argument("--newest-slots", type=int)
|
||||
parser.add_argument("--revival-slots", type=int)
|
||||
parser.add_argument("--restore", nargs="*", default=None, help="Admin restore object IDs from permanent/violation hidden to rotation hidden.")
|
||||
parser.add_argument("--hide-violation", nargs="*", default=None, help="Admin hide object IDs as violation_hidden.")
|
||||
return parser.parse_args()
|
||||
|
|
@ -298,7 +326,7 @@ def parse_args() -> argparse.Namespace:
|
|||
def main() -> int:
|
||||
args = parse_args()
|
||||
state = load_json(args.world_json)
|
||||
config = RotationConfig(display_limit=args.display_limit, newest_slots=args.newest_slots, revival_slots=args.revival_slots)
|
||||
config = load_config(args.config, {"display_limit": args.display_limit, "newest_slots": args.newest_slots, "revival_slots": args.revival_slots})
|
||||
summary: Dict[str, Any] = {}
|
||||
if args.restore:
|
||||
summary["restored"] = restore_permanent(state, args.restore)
|
||||
|
|
|
|||
64
server/test_rotation_worker.py
Normal file
64
server/test_rotation_worker.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import tempfile
|
||||
import unittest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from rotation_worker import (
|
||||
ACTIVE,
|
||||
HIDDEN_ROTATION,
|
||||
VIOLATION_HIDDEN,
|
||||
RotationConfig,
|
||||
account_publish_limit,
|
||||
apply_exhibition_cap,
|
||||
hide_violation,
|
||||
load_config,
|
||||
restore_permanent,
|
||||
)
|
||||
|
||||
|
||||
class RotationWorkerTest(unittest.TestCase):
|
||||
def test_account_publish_limit_changes_after_first_day(self):
|
||||
now = 2_000_000_000
|
||||
self.assertEqual(account_publish_limit({"id": "a", "createdAt": now}, now), 5)
|
||||
self.assertEqual(account_publish_limit({"id": "a", "createdAt": now - 25 * 60 * 60 * 1000}, now), 10)
|
||||
|
||||
def test_exhibition_cap_hides_old_excess_objects(self):
|
||||
state = {
|
||||
"assets": [],
|
||||
"placed": [
|
||||
{"id": "old", "assetId": "a", "x": 1, "y": 1, "publishedAt": 10},
|
||||
{"id": "new", "assetId": "b", "x": 2, "y": 2, "publishedAt": 20},
|
||||
],
|
||||
"dynamicSummons": [],
|
||||
"objectVotes": {},
|
||||
}
|
||||
summary = apply_exhibition_cap(state, RotationConfig(display_limit=1, newest_slots=1, revival_slots=0), seed=1, now=30)
|
||||
self.assertEqual(summary["active"], 1)
|
||||
self.assertEqual(state["placed"][1]["status"], ACTIVE)
|
||||
self.assertEqual(state["placed"][0]["status"], HIDDEN_ROTATION)
|
||||
|
||||
def test_display_limit_caps_slot_total(self):
|
||||
config = RotationConfig(display_limit=3, newest_slots=5, revival_slots=5)
|
||||
self.assertEqual(config.newest_slots + config.revival_slots, 3)
|
||||
|
||||
def test_violation_hide_and_restore(self):
|
||||
state = {"placed": [{"id": "obj", "assetId": "a"}], "dynamicSummons": []}
|
||||
self.assertEqual(hide_violation(state, ["obj"], now=1), ["obj"])
|
||||
self.assertEqual(state["placed"][0]["status"], VIOLATION_HIDDEN)
|
||||
self.assertEqual(restore_permanent(state, ["obj"], now=2), ["obj"])
|
||||
self.assertEqual(state["placed"][0]["status"], HIDDEN_ROTATION)
|
||||
|
||||
def test_load_config_from_json_with_override(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "policy.json"
|
||||
path.write_text('{"display_limit": 40, "newest_slots": 30}', encoding="utf-8")
|
||||
config = load_config(path, {"revival_slots": 7})
|
||||
self.assertEqual(config.display_limit, 40)
|
||||
self.assertEqual(config.newest_slots, 30)
|
||||
self.assertEqual(config.revival_slots, 7)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
166
server/test_world_policy.py
Normal file
166
server/test_world_policy.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import unittest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from world_policy import (
|
||||
ACTIVE,
|
||||
VIOLATION_HIDDEN,
|
||||
apply_command,
|
||||
apply_server_event,
|
||||
can_delete_asset,
|
||||
can_import_full_state,
|
||||
can_modify_object,
|
||||
make_dynamic_move_event,
|
||||
make_world_phase_event,
|
||||
)
|
||||
|
||||
|
||||
def base_state():
|
||||
return {
|
||||
"worldMode": "shared",
|
||||
"assets": [
|
||||
{"id": "asset-a", "ownerAccountId": "alice", "author": "Alice", "version": 1},
|
||||
{"id": "asset-b", "ownerAccountId": "bob", "author": "Bob", "version": 1},
|
||||
],
|
||||
"placed": [
|
||||
{"id": "obj-a", "assetId": "asset-a", "ownerAccountId": "alice", "x": 1, "y": 1, "version": 1, "status": ACTIVE}
|
||||
],
|
||||
"dynamicSummons": [
|
||||
{"id": "dyn-b", "assetId": "asset-b", "ownerAccountId": "bob", "homeX": 2, "homeY": 2, "version": 1, "status": ACTIVE}
|
||||
],
|
||||
"objectVotes": {},
|
||||
"assetVotes": {},
|
||||
"hiddenObjects": {},
|
||||
"hiddenAssets": {},
|
||||
"moderationReports": [],
|
||||
"tombstones": {"assets": {}, "objects": {}},
|
||||
"serverSync": {},
|
||||
}
|
||||
|
||||
|
||||
class WorldPolicyTest(unittest.TestCase):
|
||||
def test_non_owner_cannot_delete_static_object(self):
|
||||
state = base_state()
|
||||
allowed, meta = can_modify_object(state, {"id": "bob"}, "obj-a")
|
||||
self.assertFalse(allowed)
|
||||
self.assertEqual(meta["reason"], "not_object_owner")
|
||||
|
||||
result = apply_command(state, {"id": "bob"}, {"type": "object.delete", "kind": "static", "objectId": "obj-a"}, at=100)
|
||||
self.assertFalse(result["accepted"])
|
||||
self.assertEqual(result["reason"], "not_object_owner")
|
||||
self.assertEqual(len(state["placed"]), 1)
|
||||
|
||||
def test_non_owner_cannot_move_dynamic_object(self):
|
||||
state = base_state()
|
||||
command = {
|
||||
"type": "object.move",
|
||||
"kind": "dynamic",
|
||||
"object": {"id": "dyn-b", "assetId": "asset-b", "homeX": 9, "homeY": 9, "version": 1},
|
||||
}
|
||||
result = apply_command(state, {"id": "alice"}, command, at=100)
|
||||
self.assertFalse(result["accepted"])
|
||||
self.assertEqual(result["reason"], "not_object_owner")
|
||||
|
||||
def test_owner_can_move_and_delete_own_object(self):
|
||||
state = base_state()
|
||||
move = apply_command(
|
||||
state,
|
||||
{"id": "bob"},
|
||||
{"type": "object.move", "kind": "dynamic", "object": {"id": "dyn-b", "assetId": "asset-b", "homeX": 9, "homeY": 9, "version": 1}},
|
||||
at=100,
|
||||
)
|
||||
self.assertTrue(move["accepted"])
|
||||
self.assertTrue(apply_server_event(state, move["event"]))
|
||||
self.assertEqual(state["dynamicSummons"][0]["homeX"], 9)
|
||||
self.assertEqual(state["dynamicSummons"][0]["ownerAccountId"], "bob")
|
||||
|
||||
delete = apply_command(state, {"id": "bob"}, {"type": "object.delete", "kind": "dynamic", "objectId": "dyn-b"}, at=200)
|
||||
self.assertTrue(delete["accepted"])
|
||||
self.assertTrue(apply_server_event(state, delete["event"]))
|
||||
self.assertEqual(state["dynamicSummons"], [])
|
||||
self.assertIn("dyn-b", state["tombstones"]["objects"])
|
||||
|
||||
def test_admin_can_violation_hide_any_object(self):
|
||||
state = base_state()
|
||||
result = apply_command(state, {"id": "mod", "admin": True}, {"type": "admin.hide_violation", "objectId": "obj-a"}, at=100)
|
||||
self.assertTrue(result["accepted"])
|
||||
self.assertTrue(apply_server_event(state, result["event"]))
|
||||
self.assertEqual(state["placed"][0]["status"], VIOLATION_HIDDEN)
|
||||
self.assertTrue(state["placed"][0]["permanentHidden"])
|
||||
|
||||
def test_deleted_object_cannot_be_resurrected_by_stale_upsert(self):
|
||||
state = base_state()
|
||||
delete = apply_command(state, {"id": "alice"}, {"type": "object.delete", "kind": "static", "objectId": "obj-a"}, at=100)
|
||||
self.assertTrue(apply_server_event(state, delete["event"]))
|
||||
stale = {
|
||||
"serverEventId": "sev_90_object_upsert",
|
||||
"serverAt": 90,
|
||||
"actorAccountId": "alice",
|
||||
"type": "object.upsert",
|
||||
"kind": "static",
|
||||
"object": {"id": "obj-a", "assetId": "asset-a", "ownerAccountId": "alice", "x": 7, "y": 7, "version": 1},
|
||||
"objectVersion": 1,
|
||||
}
|
||||
self.assertFalse(apply_server_event(state, stale))
|
||||
self.assertEqual(state["placed"], [])
|
||||
|
||||
def test_asset_delete_cascades_and_tombstones_owned_objects(self):
|
||||
state = base_state()
|
||||
allowed, _ = can_delete_asset(state, {"id": "alice"}, "asset-a")
|
||||
self.assertTrue(allowed)
|
||||
result = apply_command(state, {"id": "alice"}, {"type": "asset.delete", "assetId": "asset-a"}, at=100)
|
||||
self.assertTrue(result["accepted"])
|
||||
self.assertTrue(apply_server_event(state, result["event"]))
|
||||
self.assertNotIn("asset-a", [asset["id"] for asset in state["assets"]])
|
||||
self.assertEqual(state["placed"], [])
|
||||
self.assertIn("asset-a", state["tombstones"]["assets"])
|
||||
self.assertIn("obj-a", state["tombstones"]["objects"])
|
||||
self.assertEqual(len(state["dynamicSummons"]), 1)
|
||||
|
||||
def test_non_owner_can_delete_asset_and_all_placements(self):
|
||||
state = base_state()
|
||||
allowed, _ = can_delete_asset(state, {"id": "alice"}, "asset-b")
|
||||
self.assertTrue(allowed)
|
||||
result = apply_command(state, {"id": "alice"}, {"type": "asset.delete", "assetId": "asset-b"}, at=100)
|
||||
self.assertTrue(result["accepted"])
|
||||
self.assertTrue(apply_server_event(state, result["event"]))
|
||||
self.assertNotIn("asset-b", [asset["id"] for asset in state["assets"]])
|
||||
self.assertEqual(state["dynamicSummons"], [])
|
||||
self.assertIn("asset-b", state["tombstones"]["assets"])
|
||||
self.assertIn("dyn-b", state["tombstones"]["objects"])
|
||||
|
||||
|
||||
def test_world_phase_event_updates_authoritative_clock(self):
|
||||
state = base_state()
|
||||
event = make_world_phase_event(at=600_000)
|
||||
self.assertTrue(apply_server_event(state, event))
|
||||
self.assertEqual(state["serverSync"]["clock"]["worldTimeMs"], 600_000)
|
||||
self.assertEqual(state["serverSync"]["clock"]["dayMs"], 600_000)
|
||||
self.assertEqual(state["serverSync"]["lastServerEventId"], event["serverEventId"])
|
||||
|
||||
def test_dynamic_move_event_is_server_authoritative(self):
|
||||
state = base_state()
|
||||
event = make_dynamic_move_event(state, "dyn-b", x=4.5, y=7.0, target_x=8.0, target_y=9.0, at=700, actor="server")
|
||||
self.assertIsNotNone(event)
|
||||
self.assertEqual(event["type"], "dynamic.move")
|
||||
self.assertTrue(apply_server_event(state, event))
|
||||
target = state["serverSync"]["dynamicTargets"]["dyn-b"]
|
||||
self.assertEqual(target["targetX"], 8.0)
|
||||
self.assertEqual(target["targetY"], 9.0)
|
||||
self.assertEqual(state["dynamicSummons"][0]["serverState"]["x"], 4.5)
|
||||
self.assertEqual(state["dynamicSummons"][0]["version"], 2)
|
||||
|
||||
def test_shared_full_state_import_is_rejected(self):
|
||||
state = base_state()
|
||||
allowed, meta = can_import_full_state(state, {"id": "alice"})
|
||||
self.assertFalse(allowed)
|
||||
self.assertEqual(meta["reason"], "shared_full_import_disabled")
|
||||
local_state = {**state, "worldMode": "local"}
|
||||
allowed, _ = can_import_full_state(local_state, {"id": "alice"})
|
||||
self.assertTrue(allowed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
431
server/world_policy.py
Normal file
431
server/world_policy.py
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Authoritative world mutation policy for Pixel Island.
|
||||
|
||||
The browser may preview local changes, but shared worlds should apply only the
|
||||
server events produced here. This module focuses on ownership and destructive
|
||||
mutation safety; rotation and moderation policy remain separate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, Iterable, List, MutableMapping, Optional, Tuple
|
||||
|
||||
ACTIVE = "active"
|
||||
VIOLATION_HIDDEN = "violation_hidden"
|
||||
|
||||
OBJECT_KEYS = {"static": "placed", "dynamic": "dynamicSummons"}
|
||||
DAY_MS = 10 * 60 * 1000
|
||||
|
||||
|
||||
def now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def account_id(account: MutableMapping[str, Any] | None) -> str:
|
||||
return str((account or {}).get("id") or "")
|
||||
|
||||
|
||||
def is_admin(account: MutableMapping[str, Any] | None) -> bool:
|
||||
return bool((account or {}).get("admin") or (account or {}).get("role") == "admin")
|
||||
|
||||
|
||||
def normalize_tombstones(state: MutableMapping[str, Any]) -> MutableMapping[str, Any]:
|
||||
tombstones = state.setdefault("tombstones", {})
|
||||
tombstones.setdefault("assets", {})
|
||||
tombstones.setdefault("objects", {})
|
||||
return tombstones
|
||||
|
||||
|
||||
def iter_objects(state: MutableMapping[str, Any]) -> Iterable[Tuple[str, MutableMapping[str, Any]]]:
|
||||
for kind, key in OBJECT_KEYS.items():
|
||||
for obj in state.get(key) or []:
|
||||
if isinstance(obj, MutableMapping) and obj.get("id"):
|
||||
yield kind, obj
|
||||
|
||||
|
||||
def find_object(state: MutableMapping[str, Any], object_id: str, kind: Optional[str] = None) -> Tuple[Optional[str], Optional[MutableMapping[str, Any]]]:
|
||||
kinds = [kind] if kind in OBJECT_KEYS else list(OBJECT_KEYS)
|
||||
for item_kind in kinds:
|
||||
for obj in state.get(OBJECT_KEYS[item_kind]) or []:
|
||||
if str(obj.get("id") or "") == str(object_id):
|
||||
return item_kind, obj
|
||||
return None, None
|
||||
|
||||
|
||||
def find_asset(state: MutableMapping[str, Any], asset_id: str) -> Optional[MutableMapping[str, Any]]:
|
||||
for asset in state.get("assets") or []:
|
||||
if isinstance(asset, MutableMapping) and str(asset.get("id") or "") == str(asset_id):
|
||||
return asset
|
||||
return None
|
||||
|
||||
|
||||
def owner_of_object(state: MutableMapping[str, Any], obj: MutableMapping[str, Any]) -> str:
|
||||
if obj.get("ownerAccountId"):
|
||||
return str(obj.get("ownerAccountId"))
|
||||
asset = find_asset(state, str(obj.get("assetId") or ""))
|
||||
return str((asset or {}).get("ownerAccountId") or "")
|
||||
|
||||
|
||||
def can_modify_object(state: MutableMapping[str, Any], account: MutableMapping[str, Any] | None, object_id: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
actor = account_id(account)
|
||||
if not actor:
|
||||
return False, {"reason": "account_required"}
|
||||
kind, obj = find_object(state, object_id)
|
||||
if not obj:
|
||||
return False, {"reason": "object_not_found"}
|
||||
owner = owner_of_object(state, obj)
|
||||
if actor == owner or is_admin(account):
|
||||
return True, {"reason": None, "kind": kind, "ownerAccountId": owner}
|
||||
return False, {"reason": "not_object_owner", "ownerAccountId": owner}
|
||||
|
||||
|
||||
def can_delete_asset(state: MutableMapping[str, Any], account: MutableMapping[str, Any] | None, asset_id: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
actor = account_id(account)
|
||||
if not actor:
|
||||
return False, {"reason": "account_required"}
|
||||
asset = find_asset(state, asset_id)
|
||||
if not asset:
|
||||
return False, {"reason": "asset_not_found"}
|
||||
owner = str(asset.get("ownerAccountId") or "")
|
||||
return True, {"reason": None, "ownerAccountId": owner}
|
||||
|
||||
|
||||
def is_object_tombstoned(state: MutableMapping[str, Any], object_id: str, version: int = 0) -> bool:
|
||||
tomb = normalize_tombstones(state)["objects"].get(str(object_id))
|
||||
return bool(tomb and int(tomb.get("version") or 0) >= int(version or 0))
|
||||
|
||||
|
||||
def is_asset_tombstoned(state: MutableMapping[str, Any], asset_id: str, version: int = 0) -> bool:
|
||||
tomb = normalize_tombstones(state)["assets"].get(str(asset_id))
|
||||
return bool(tomb and int(tomb.get("version") or 0) >= int(version or 0))
|
||||
|
||||
|
||||
def _server_event(event_type: str, actor: str, payload: Dict[str, Any], at: Optional[int] = None) -> Dict[str, Any]:
|
||||
stamp = at or now_ms()
|
||||
return {
|
||||
"serverEventId": f"sev_{stamp}_{event_type.replace('.', '_')}",
|
||||
"serverAt": stamp,
|
||||
"actorAccountId": actor,
|
||||
"type": event_type,
|
||||
**payload,
|
||||
}
|
||||
|
||||
|
||||
def _accepted(event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"accepted": True, "event": event}
|
||||
|
||||
|
||||
def phase_key_for_time(world_time_ms: int, day_ms: int = DAY_MS) -> str:
|
||||
progress = (int(world_time_ms) % int(day_ms or DAY_MS)) / float(day_ms or DAY_MS)
|
||||
if progress < 0.10:
|
||||
return "pre_dawn"
|
||||
if progress < 0.20:
|
||||
return "sunrise"
|
||||
if progress < 0.60:
|
||||
return "day"
|
||||
if progress < 0.72:
|
||||
return "sunset"
|
||||
return "night"
|
||||
|
||||
|
||||
def make_world_phase_event(at: Optional[int] = None, day_ms: int = DAY_MS) -> Dict[str, Any]:
|
||||
"""Create the authoritative day/night clock event emitted by the server."""
|
||||
stamp = at or now_ms()
|
||||
cycle = int(day_ms or DAY_MS)
|
||||
world_time_ms = int(stamp)
|
||||
progress = (world_time_ms % cycle) / float(cycle)
|
||||
return _server_event(
|
||||
"world.phase",
|
||||
"server",
|
||||
{
|
||||
"worldTimeMs": world_time_ms,
|
||||
"dayMs": cycle,
|
||||
"phase": {"key": phase_key_for_time(world_time_ms, cycle), "progress": progress},
|
||||
},
|
||||
stamp,
|
||||
)
|
||||
|
||||
|
||||
def make_dynamic_move_event(
|
||||
state: MutableMapping[str, Any],
|
||||
object_id: str,
|
||||
x: float,
|
||||
y: float,
|
||||
target_x: Optional[float] = None,
|
||||
target_y: Optional[float] = None,
|
||||
at: Optional[int] = None,
|
||||
actor: str = "server",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Create an authoritative dynamic-object movement event.
|
||||
|
||||
Clients may ask for a target, but shared-world dynamic positions are only
|
||||
changed after this server event is applied.
|
||||
"""
|
||||
stamp = at or now_ms()
|
||||
kind, obj = find_object(state, object_id, "dynamic")
|
||||
if kind != "dynamic" or not obj:
|
||||
return None
|
||||
next_object = deepcopy(dict(obj))
|
||||
target = {
|
||||
"objectId": str(object_id),
|
||||
"x": float(x),
|
||||
"y": float(y),
|
||||
"targetX": float(target_x if target_x is not None else x),
|
||||
"targetY": float(target_y if target_y is not None else y),
|
||||
"homeX": float(next_object.get("homeX", x)),
|
||||
"homeY": float(next_object.get("homeY", y)),
|
||||
"serverAt": stamp,
|
||||
}
|
||||
next_object["serverState"] = target
|
||||
next_object["version"] = int(next_object.get("version") or 0) + 1
|
||||
return _server_event(
|
||||
"dynamic.move",
|
||||
actor or "server",
|
||||
{
|
||||
"objectId": str(object_id),
|
||||
"object": next_object,
|
||||
"x": target["x"],
|
||||
"y": target["y"],
|
||||
"targetX": target["targetX"],
|
||||
"targetY": target["targetY"],
|
||||
"homeX": target["homeX"],
|
||||
"homeY": target["homeY"],
|
||||
"objectVersion": next_object["version"],
|
||||
},
|
||||
stamp,
|
||||
)
|
||||
|
||||
|
||||
def _rejected(reason: str, **extra: Any) -> Dict[str, Any]:
|
||||
return {"accepted": False, "reason": reason, **extra}
|
||||
|
||||
|
||||
def _copy_owned_asset(asset: MutableMapping[str, Any], owner: str) -> Dict[str, Any]:
|
||||
copied = deepcopy(dict(asset))
|
||||
copied["ownerAccountId"] = owner
|
||||
copied.setdefault("author", owner)
|
||||
copied.setdefault("version", 1)
|
||||
return copied
|
||||
|
||||
|
||||
def _copy_owned_object(obj: MutableMapping[str, Any], owner: str) -> Dict[str, Any]:
|
||||
copied = deepcopy(dict(obj))
|
||||
copied["ownerAccountId"] = owner
|
||||
copied["version"] = int(copied.get("version") or 0) + 1
|
||||
copied.setdefault("status", ACTIVE)
|
||||
return copied
|
||||
|
||||
|
||||
def apply_command(state: MutableMapping[str, Any], account: MutableMapping[str, Any] | None, command: MutableMapping[str, Any], at: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""Validate a client command and return a server event without mutating state."""
|
||||
actor = account_id(account)
|
||||
if not actor:
|
||||
return _rejected("account_required")
|
||||
command_type = str(command.get("type") or "")
|
||||
|
||||
if command_type == "asset.create":
|
||||
asset = command.get("asset")
|
||||
if not isinstance(asset, MutableMapping) or not asset.get("id"):
|
||||
return _rejected("invalid_asset")
|
||||
if is_asset_tombstoned(state, str(asset["id"]), int(asset.get("version") or 1)):
|
||||
return _rejected("asset_tombstoned")
|
||||
return _accepted(_server_event("asset.upsert", actor, {"asset": _copy_owned_asset(asset, actor)}, at))
|
||||
|
||||
if command_type in {"object.publish", "object.move"}:
|
||||
kind = str(command.get("kind") or "static")
|
||||
obj = command.get("object")
|
||||
if kind not in OBJECT_KEYS or not isinstance(obj, MutableMapping) or not obj.get("id"):
|
||||
return _rejected("invalid_object")
|
||||
asset = find_asset(state, str(obj.get("assetId") or ""))
|
||||
if not asset:
|
||||
return _rejected("asset_not_found")
|
||||
_, existing = find_object(state, str(obj["id"]), kind)
|
||||
if existing:
|
||||
allowed, meta = can_modify_object(state, account, str(obj["id"]))
|
||||
if not allowed:
|
||||
return _rejected(meta["reason"], **{k: v for k, v in meta.items() if k != "reason"})
|
||||
owner = owner_of_object(state, existing)
|
||||
else:
|
||||
if str(asset.get("ownerAccountId") or "") != actor and not is_admin(account):
|
||||
return _rejected("not_asset_owner", ownerAccountId=asset.get("ownerAccountId"))
|
||||
owner = actor
|
||||
next_object = _copy_owned_object(obj, owner)
|
||||
if is_object_tombstoned(state, str(next_object["id"]), int(next_object.get("version") or 1)):
|
||||
return _rejected("object_tombstoned")
|
||||
return _accepted(_server_event("object.upsert", actor, {"kind": kind, "object": next_object, "objectVersion": next_object["version"]}, at))
|
||||
|
||||
if command_type == "object.delete":
|
||||
object_id = str(command.get("objectId") or "")
|
||||
kind = str(command.get("kind") or "")
|
||||
allowed, meta = can_modify_object(state, account, object_id)
|
||||
if not allowed:
|
||||
return _rejected(meta["reason"], **{k: v for k, v in meta.items() if k != "reason"})
|
||||
found_kind, obj = find_object(state, object_id, kind if kind in OBJECT_KEYS else None)
|
||||
version = int((obj or {}).get("version") or 0) + 1
|
||||
tombstone = {"id": object_id, "deletedAt": at or now_ms(), "deletedBy": actor, "version": version}
|
||||
return _accepted(_server_event("object.delete", actor, {"kind": found_kind, "objectId": object_id, "objectVersion": version, "tombstone": tombstone}, at))
|
||||
|
||||
if command_type == "asset.delete":
|
||||
asset_id = str(command.get("assetId") or "")
|
||||
allowed, meta = can_delete_asset(state, account, asset_id)
|
||||
if not allowed:
|
||||
return _rejected(meta["reason"], **{k: v for k, v in meta.items() if k != "reason"})
|
||||
asset = find_asset(state, asset_id) or {}
|
||||
version = int(asset.get("version") or 0) + 1
|
||||
object_tombstones: List[Dict[str, Any]] = []
|
||||
for _, obj in iter_objects(state):
|
||||
if str(obj.get("assetId") or "") == asset_id:
|
||||
object_tombstones.append({"id": str(obj["id"]), "deletedAt": at or now_ms(), "deletedBy": actor, "version": int(obj.get("version") or 0) + 1})
|
||||
tombstone = {"id": asset_id, "deletedAt": at or now_ms(), "deletedBy": actor, "version": version}
|
||||
return _accepted(_server_event("asset.delete", actor, {"assetId": asset_id, "assetVersion": version, "tombstone": tombstone, "objectTombstones": object_tombstones}, at))
|
||||
|
||||
if command_type == "admin.hide_violation":
|
||||
if not is_admin(account):
|
||||
return _rejected("admin_required")
|
||||
object_id = str(command.get("objectId") or "")
|
||||
kind, obj = find_object(state, object_id)
|
||||
if not obj:
|
||||
return _rejected("object_not_found")
|
||||
next_object = deepcopy(dict(obj))
|
||||
next_object["status"] = VIOLATION_HIDDEN
|
||||
next_object["permanentHidden"] = True
|
||||
next_object["hiddenReason"] = "moderation_violation"
|
||||
next_object["version"] = int(next_object.get("version") or 0) + 1
|
||||
return _accepted(_server_event("object.upsert", actor, {"kind": kind, "object": next_object, "objectVersion": next_object["version"]}, at))
|
||||
|
||||
return _rejected("unknown_command")
|
||||
|
||||
|
||||
def apply_server_event(state: MutableMapping[str, Any], event: MutableMapping[str, Any]) -> bool:
|
||||
"""Apply only server-issued events. Returns True when state changed."""
|
||||
if not event or not event.get("serverEventId") or not event.get("serverAt"):
|
||||
return False
|
||||
normalize_tombstones(state)
|
||||
event_type = str(event.get("type") or "")
|
||||
|
||||
if event_type == "world.phase":
|
||||
clock = state.setdefault("serverSync", {}).setdefault("clock", {})
|
||||
day_ms = int(event.get("dayMs") or DAY_MS)
|
||||
world_time_ms = int(event.get("worldTimeMs") or event.get("serverNow") or event.get("serverAt") or now_ms())
|
||||
clock.clear()
|
||||
clock.update({
|
||||
"worldTimeMs": world_time_ms,
|
||||
"syncedAt": int(event.get("serverAt") or now_ms()),
|
||||
"dayMs": day_ms,
|
||||
"phase": event.get("phase") or {"key": phase_key_for_time(world_time_ms, day_ms), "progress": (world_time_ms % day_ms) / float(day_ms)},
|
||||
"serverEventId": event["serverEventId"],
|
||||
})
|
||||
state["serverSync"]["lastServerEventId"] = event["serverEventId"]
|
||||
return True
|
||||
|
||||
if event_type == "dynamic.move":
|
||||
object_id = str(event.get("objectId") or event.get("dynamicId") or "")
|
||||
obj = event.get("object")
|
||||
if not object_id and isinstance(obj, MutableMapping):
|
||||
object_id = str(obj.get("id") or "")
|
||||
if not object_id:
|
||||
return False
|
||||
object_version = int((obj or {}).get("version") or event.get("objectVersion") or 1) if isinstance(obj, MutableMapping) else int(event.get("objectVersion") or 1)
|
||||
if is_object_tombstoned(state, object_id, object_version):
|
||||
return False
|
||||
target = {
|
||||
"objectId": object_id,
|
||||
"x": float(event.get("x", (obj or {}).get("serverState", {}).get("x", (obj or {}).get("homeX", 0))) if isinstance(obj, MutableMapping) else event.get("x", 0)),
|
||||
"y": float(event.get("y", (obj or {}).get("serverState", {}).get("y", (obj or {}).get("homeY", 0))) if isinstance(obj, MutableMapping) else event.get("y", 0)),
|
||||
"targetX": float(event.get("targetX", event.get("x", 0))),
|
||||
"targetY": float(event.get("targetY", event.get("y", 0))),
|
||||
"homeX": float(event.get("homeX", (obj or {}).get("homeX", 0)) if isinstance(obj, MutableMapping) else event.get("homeX", 0)),
|
||||
"homeY": float(event.get("homeY", (obj or {}).get("homeY", 0)) if isinstance(obj, MutableMapping) else event.get("homeY", 0)),
|
||||
"serverAt": int(event.get("serverAt") or now_ms()),
|
||||
}
|
||||
server_sync = state.setdefault("serverSync", {})
|
||||
server_sync.setdefault("dynamicTargets", {})[object_id] = target
|
||||
if isinstance(obj, MutableMapping) and obj.get("id"):
|
||||
rows = state.setdefault("dynamicSummons", [])
|
||||
next_object = deepcopy(dict(obj))
|
||||
next_object["serverState"] = target
|
||||
index = next((i for i, item in enumerate(rows) if str(item.get("id") or "") == object_id), -1)
|
||||
if index >= 0:
|
||||
rows[index] = next_object
|
||||
else:
|
||||
rows.append(next_object)
|
||||
server_sync["lastServerEventId"] = event["serverEventId"]
|
||||
return True
|
||||
|
||||
if event_type == "asset.upsert":
|
||||
asset = event.get("asset")
|
||||
if not isinstance(asset, MutableMapping) or not asset.get("id"):
|
||||
return False
|
||||
if is_asset_tombstoned(state, str(asset["id"]), int(asset.get("version") or 1)):
|
||||
return False
|
||||
assets = state.setdefault("assets", [])
|
||||
index = next((i for i, item in enumerate(assets) if item.get("id") == asset.get("id")), -1)
|
||||
if index >= 0:
|
||||
assets[index] = deepcopy(dict(asset))
|
||||
else:
|
||||
assets.insert(0, deepcopy(dict(asset)))
|
||||
state.setdefault("serverSync", {})["lastServerEventId"] = event["serverEventId"]
|
||||
return True
|
||||
|
||||
if event_type == "object.upsert":
|
||||
obj = event.get("object")
|
||||
kind = str(event.get("kind") or "static")
|
||||
if kind not in OBJECT_KEYS or not isinstance(obj, MutableMapping) or not obj.get("id"):
|
||||
return False
|
||||
if is_object_tombstoned(state, str(obj["id"]), int(obj.get("version") or event.get("objectVersion") or 1)):
|
||||
return False
|
||||
rows = state.setdefault(OBJECT_KEYS[kind], [])
|
||||
index = next((i for i, item in enumerate(rows) if item.get("id") == obj.get("id")), -1)
|
||||
if index >= 0:
|
||||
rows[index] = deepcopy(dict(obj))
|
||||
else:
|
||||
rows.append(deepcopy(dict(obj)))
|
||||
state.setdefault("serverSync", {})["lastServerEventId"] = event["serverEventId"]
|
||||
return True
|
||||
|
||||
if event_type == "object.delete":
|
||||
object_id = str(event.get("objectId") or "")
|
||||
kind = str(event.get("kind") or "")
|
||||
if not object_id or kind not in OBJECT_KEYS:
|
||||
return False
|
||||
tombstone = event.get("tombstone") or {"id": object_id, "deletedAt": event["serverAt"], "deletedBy": event.get("actorAccountId"), "version": event.get("objectVersion") or 1}
|
||||
state["tombstones"]["objects"][object_id] = tombstone
|
||||
state[OBJECT_KEYS[kind]] = [obj for obj in state.get(OBJECT_KEYS[kind]) or [] if str(obj.get("id") or "") != object_id]
|
||||
state.setdefault("objectVotes", {}).pop(object_id, None)
|
||||
state.setdefault("hiddenObjects", {}).pop(object_id, None)
|
||||
state.setdefault("serverSync", {}).setdefault("dynamicTargets", {}).pop(object_id, None)
|
||||
state["moderationReports"] = [report for report in state.get("moderationReports") or [] if report.get("objectId") != object_id]
|
||||
state.setdefault("serverSync", {})["lastServerEventId"] = event["serverEventId"]
|
||||
return True
|
||||
|
||||
if event_type == "asset.delete":
|
||||
asset_id = str(event.get("assetId") or "")
|
||||
if not asset_id:
|
||||
return False
|
||||
state["tombstones"]["assets"][asset_id] = event.get("tombstone") or {"id": asset_id, "deletedAt": event["serverAt"], "deletedBy": event.get("actorAccountId"), "version": event.get("assetVersion") or 1}
|
||||
for tombstone in event.get("objectTombstones") or []:
|
||||
if tombstone.get("id"):
|
||||
state["tombstones"]["objects"][str(tombstone["id"])] = tombstone
|
||||
removed_object_ids = {str(t.get("id")) for t in event.get("objectTombstones") or [] if t.get("id")}
|
||||
state["assets"] = [asset for asset in state.get("assets") or [] if str(asset.get("id") or "") != asset_id]
|
||||
for key in OBJECT_KEYS.values():
|
||||
state[key] = [obj for obj in state.get(key) or [] if str(obj.get("id") or "") not in removed_object_ids]
|
||||
state.setdefault("assetVotes", {}).pop(asset_id, None)
|
||||
state.setdefault("hiddenAssets", {}).pop(asset_id, None)
|
||||
dynamic_targets = state.setdefault("serverSync", {}).setdefault("dynamicTargets", {})
|
||||
for object_id in removed_object_ids:
|
||||
state.setdefault("objectVotes", {}).pop(object_id, None)
|
||||
state.setdefault("hiddenObjects", {}).pop(object_id, None)
|
||||
dynamic_targets.pop(object_id, None)
|
||||
state["moderationReports"] = [report for report in state.get("moderationReports") or [] if report.get("objectId") not in removed_object_ids]
|
||||
state.setdefault("serverSync", {})["lastServerEventId"] = event["serverEventId"]
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def can_import_full_state(state: MutableMapping[str, Any], account: MutableMapping[str, Any] | None) -> Tuple[bool, Dict[str, Any]]:
|
||||
if state.get("worldMode") == "shared":
|
||||
return False, {"reason": "shared_full_import_disabled"}
|
||||
return True, {"reason": None}
|
||||
1873
styles.css
1873
styles.css
File diff suppressed because it is too large
Load diff
901
styles.css.bak
901
styles.css.bak
|
|
@ -1,901 +0,0 @@
|
|||
:root {
|
||||
--ink: #243044;
|
||||
--muted: #6f7b91;
|
||||
--paper: #fff7e8;
|
||||
--panel: rgba(255, 248, 232, .94);
|
||||
--panel-solid: #fff8e9;
|
||||
--panel-2: #ffeec9;
|
||||
--line: #2d3c50;
|
||||
--line-soft: rgba(45, 60, 80, .18);
|
||||
--pink: #ff85b3;
|
||||
--pink-dark: #f05f98;
|
||||
--mint: #73d6a4;
|
||||
--sky: #77c9ff;
|
||||
--sun: #ffd66e;
|
||||
--danger: #ff6b6b;
|
||||
--shadow: 6px 6px 0 rgba(34, 45, 63, .14);
|
||||
--hard-shadow: 4px 4px 0 rgba(34, 45, 63, .22);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
background: #92d8ff;
|
||||
font-family: "Courier New", "Monaco", "Lucida Console", ui-monospace, monospace;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { -webkit-tap-highlight-color: transparent; }
|
||||
|
||||
.app {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#worldCanvas {
|
||||
display: block;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
image-rendering: pixelated;
|
||||
cursor: grab;
|
||||
background: #8cd4ff;
|
||||
}
|
||||
|
||||
#worldCanvas.dragging { cursor: grabbing; }
|
||||
#worldCanvas.placeCursor { cursor: copy; }
|
||||
#worldCanvas.eraseCursor { cursor: not-allowed; }
|
||||
|
||||
.hud {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
background: var(--panel);
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 0;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.topHud {
|
||||
top: 14px;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
min-height: 70px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-weight: 900;
|
||||
letter-spacing: .04em;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.logo span {
|
||||
display: inline-block;
|
||||
background: var(--pink);
|
||||
border: 2px solid var(--line);
|
||||
padding: 1px 6px;
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
.subline {
|
||||
margin-top: 3px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
.authorCard {
|
||||
width: 190px;
|
||||
border: 2px solid var(--line);
|
||||
background: #fffdf5;
|
||||
padding: 6px 8px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.authorCard span {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.authorCard input {
|
||||
margin-top: 3px;
|
||||
padding: 5px 6px;
|
||||
border-width: 2px;
|
||||
background: #fffaf0;
|
||||
}
|
||||
|
||||
.clockCard {
|
||||
width: 230px;
|
||||
border: 2px solid var(--line);
|
||||
background: #fffdf5;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.phaseLabel {
|
||||
font-weight: 900;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.phaseBar {
|
||||
margin-top: 6px;
|
||||
height: 10px;
|
||||
background: #dfe8f0;
|
||||
border: 2px solid var(--line);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.phaseBar span {
|
||||
display: block;
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #ffd66e, #77c9ff, #6862e8);
|
||||
}
|
||||
|
||||
.clockHint {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.edgeAdd {
|
||||
position: absolute;
|
||||
z-index: 8;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 56px;
|
||||
height: 86px;
|
||||
transform: translateY(-50%);
|
||||
border: 3px solid var(--line);
|
||||
border-left: 0;
|
||||
border-radius: 0 18px 18px 0;
|
||||
background: var(--pink);
|
||||
color: white;
|
||||
font-size: 42px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
box-shadow: var(--hard-shadow);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edgeAdd:hover { background: var(--pink-dark); }
|
||||
.edgeAdd:active { transform: translateY(calc(-50% + 2px)); box-shadow: 2px 2px 0 rgba(34,45,63,.22); }
|
||||
|
||||
.toolHud {
|
||||
right: 14px;
|
||||
top: 116px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 7px;
|
||||
padding: 9px;
|
||||
}
|
||||
|
||||
.iconButton,
|
||||
button,
|
||||
.ghostButton,
|
||||
.tab,
|
||||
.tool,
|
||||
.segmented button {
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 0;
|
||||
background: #fffdf5;
|
||||
color: var(--ink);
|
||||
padding: 8px 11px;
|
||||
font-weight: 850;
|
||||
cursor: pointer;
|
||||
box-shadow: 3px 3px 0 rgba(36, 48, 68, .16);
|
||||
transition: transform .05s, box-shadow .05s, background .12s;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
.ghostButton:hover,
|
||||
.tab:hover,
|
||||
.tool:hover,
|
||||
.segmented button:hover { background: #fff1c8; }
|
||||
|
||||
button:active,
|
||||
.ghostButton:active,
|
||||
.tab:active,
|
||||
.tool:active,
|
||||
.segmented button:active {
|
||||
transform: translate(2px, 2px);
|
||||
box-shadow: 1px 1px 0 rgba(36, 48, 68, .16);
|
||||
}
|
||||
|
||||
button.active,
|
||||
.tab.active,
|
||||
.tool.active,
|
||||
.segmented button.active {
|
||||
background: var(--sun);
|
||||
box-shadow: inset 0 0 0 2px rgba(255,255,255,.4), 3px 3px 0 rgba(36,48,68,.18);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--mint);
|
||||
color: #163a2a;
|
||||
}
|
||||
|
||||
button.secondary { background: var(--panel-2); }
|
||||
button.danger, .tool.danger, .iconButton.danger { background: #ffe4e4; color: #833232; }
|
||||
button.danger.active, .iconButton.danger.active { background: var(--danger); color: #fffdf5; }
|
||||
|
||||
.divider {
|
||||
height: 2px;
|
||||
background: var(--line);
|
||||
opacity: .22;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.infoHud {
|
||||
right: 14px;
|
||||
bottom: 14px;
|
||||
width: min(360px, calc(100vw - 28px));
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.selectedAsset {
|
||||
font-weight: 900;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px dashed rgba(45,60,80,.25);
|
||||
}
|
||||
|
||||
.tileInfo {
|
||||
white-space: pre-wrap;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.studioDrawer {
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: min(500px, calc(100vw - 20px));
|
||||
height: 100vh;
|
||||
background: var(--panel-solid);
|
||||
border-right: 3px solid var(--line);
|
||||
box-shadow: 10px 0 0 rgba(36, 48, 68, .12);
|
||||
transform: translateX(-104%);
|
||||
transition: transform .18s ease-out;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
}
|
||||
|
||||
.studioDrawer.open { transform: translateX(0); }
|
||||
|
||||
.drawerHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px 8px;
|
||||
border-bottom: 3px solid var(--line);
|
||||
background: #ffe6f0;
|
||||
}
|
||||
|
||||
.drawerHead h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.drawerHead p {
|
||||
margin: 3px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ghostButton {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
background: #fff2d0;
|
||||
border-bottom: 3px solid var(--line);
|
||||
}
|
||||
|
||||
.tab { padding: 8px 5px; font-size: 13px; }
|
||||
|
||||
.drawerBody {
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
background:
|
||||
linear-gradient(45deg, rgba(255,255,255,.4) 25%, transparent 25%) 0 0/18px 18px,
|
||||
var(--panel-solid);
|
||||
}
|
||||
|
||||
.tabPanel { display: none; }
|
||||
.tabPanel.active { display: block; }
|
||||
|
||||
.compactNameField { margin-bottom: 5px; }
|
||||
.roleHintInline { margin: 0 0 10px; }
|
||||
|
||||
.card {
|
||||
background: #fffdf5;
|
||||
border: 2px solid var(--line);
|
||||
box-shadow: 4px 4px 0 rgba(36,48,68,.12);
|
||||
padding: 9px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stack > * + * { margin-top: 10px; }
|
||||
.cardTitle {
|
||||
font-weight: 950;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
font-size: 12px;
|
||||
color: #3c4b60;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 5px;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 0;
|
||||
background: #fffaf0;
|
||||
color: var(--ink);
|
||||
padding: 9px 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus { background: #fff; box-shadow: 0 0 0 3px rgba(119,201,255,.35); }
|
||||
textarea { resize: vertical; }
|
||||
input[type="color"] { height: 39px; padding: 3px; }
|
||||
input[type="checkbox"] { width: auto; margin: 0 8px 0 0; }
|
||||
|
||||
.twoCols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.segmented button { font-size: 12px; padding: 8px 5px; }
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.editorCard { padding-bottom: 10px; }
|
||||
.editorTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.sideSwitcher {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
.sideSwitcher button { padding: 6px 7px; font-size: 11px; }
|
||||
|
||||
.toolRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
|
||||
.editorToolRow { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.editorHistoryRow { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.editorMoveRow { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.editorFileRow { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.toolRow input { margin: 0; }
|
||||
.tool { padding: 7px 5px; font-size: 11px; }
|
||||
button:disabled, .tool:disabled { opacity: .45; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
|
||||
#paintCanvas {
|
||||
display: block;
|
||||
width: min(100%, 330px);
|
||||
margin: 0 auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
image-rendering: pixelated;
|
||||
border: 3px solid var(--line);
|
||||
background:
|
||||
linear-gradient(45deg, #f4ebdc 25%, transparent 25%) 0 0/16px 16px,
|
||||
linear-gradient(45deg, transparent 75%, #f4ebdc 75%) 0 0/16px 16px,
|
||||
#fffaf0;
|
||||
cursor: crosshair;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.compactSettings .twoCols { align-items: center; }
|
||||
.toggleLine {
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.behaviorNote {
|
||||
border: 2px dashed rgba(45,60,80,.22);
|
||||
padding: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.actionRow.wrap { flex-wrap: wrap; }
|
||||
.actionRow button { flex: 1; }
|
||||
.lineageNote { min-height: 18px; color: var(--muted); font-size: 12px; }
|
||||
|
||||
.assetList { display: grid; gap: 10px; }
|
||||
.assetCard {
|
||||
display: grid;
|
||||
grid-template-columns: 68px 1fr;
|
||||
gap: 10px;
|
||||
border: 2px solid var(--line);
|
||||
background: #fff9e8;
|
||||
padding: 8px;
|
||||
box-shadow: 3px 3px 0 rgba(36,48,68,.12);
|
||||
}
|
||||
.assetCard.selected { background: #e6ffef; }
|
||||
.assetPreview {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
image-rendering: pixelated;
|
||||
background: #fff3d9;
|
||||
border: 2px solid var(--line);
|
||||
}
|
||||
.assetMeta strong { display: block; font-size: 14px; }
|
||||
.assetMeta span { display: block; margin-top: 3px; color: var(--muted); font-size: 12px; line-height: 1.3; }
|
||||
.assetActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.assetActions button { padding: 5px 7px; font-size: 11px; }
|
||||
|
||||
.modeGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.modeGrid button { padding: 8px 5px; font-size: 12px; }
|
||||
|
||||
.legend {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.legend span { display: flex; align-items: center; gap: 6px; }
|
||||
.legend i {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--line);
|
||||
}
|
||||
.legend .water { background: #67b5d9; }
|
||||
.legend .sand { background: #ead493; }
|
||||
.legend .grass { background: #8bce76; }
|
||||
.legend .highland { background: #a7be80; }
|
||||
|
||||
#dataBox { min-height: 160px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; }
|
||||
|
||||
.toast {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
left: 50%;
|
||||
bottom: 22px;
|
||||
transform: translateX(-50%);
|
||||
background: #fffdf5;
|
||||
border: 3px solid var(--line);
|
||||
box-shadow: var(--hard-shadow);
|
||||
padding: 10px 14px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.topHud { align-items: flex-start; flex-direction: column; right: 70px; }
|
||||
.clockCard, .authorCard { width: 100%; }
|
||||
.toolHud { top: auto; bottom: 122px; right: 10px; }
|
||||
.infoHud { left: 10px; right: 10px; width: auto; }
|
||||
.studioDrawer { width: calc(100vw - 18px); }
|
||||
.toolRow { grid-template-columns: 44px repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
/* refinements */
|
||||
.toolHud {
|
||||
top: 142px;
|
||||
right: 18px;
|
||||
}
|
||||
.edgeAdd {
|
||||
top: calc(50% + 24px);
|
||||
width: 62px;
|
||||
height: 92px;
|
||||
background: linear-gradient(180deg, #ff97c2, #ff75aa);
|
||||
}
|
||||
.studioDrawer {
|
||||
width: min(520px, calc(100vw - 28px));
|
||||
}
|
||||
.drawerHead { background: #ffe1ef; }
|
||||
.drawerBody {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255,255,255,.42) 0 1px, transparent 1px) 0 0/20px 20px,
|
||||
linear-gradient(0deg, rgba(255,255,255,.42) 0 1px, transparent 1px) 0 0/20px 20px,
|
||||
#fff8e9;
|
||||
}
|
||||
.card {
|
||||
border-width: 3px;
|
||||
box-shadow: 5px 5px 0 rgba(36,48,68,.13);
|
||||
}
|
||||
.heroEditor {
|
||||
background: #fffdf7;
|
||||
border-color: #26364c;
|
||||
}
|
||||
#paintCanvas {
|
||||
margin-bottom: 10px;
|
||||
max-height: min(42vh, 330px);
|
||||
}
|
||||
.toolRow {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
margin-top: 0;
|
||||
}
|
||||
.paletteGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(13, 1fr);
|
||||
gap: 3px;
|
||||
margin: 6px 0 6px;
|
||||
padding: 6px;
|
||||
border: 2px solid rgba(36,48,68,.28);
|
||||
background: #fff6df;
|
||||
}
|
||||
.paletteSwatch {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
min-height: 18px;
|
||||
border: 2px solid rgba(36,48,68,.38);
|
||||
box-shadow: 2px 2px 0 rgba(36,48,68,.13);
|
||||
cursor: pointer;
|
||||
}
|
||||
.paletteSwatch.active {
|
||||
outline: 3px solid #ff7aad;
|
||||
outline-offset: 1px;
|
||||
border-color: #26364c;
|
||||
}
|
||||
.paletteSwatch::after {
|
||||
content: attr(data-code);
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
bottom: 0;
|
||||
color: rgba(20,26,38,.52);
|
||||
font-size: 7px;
|
||||
font-weight: 900;
|
||||
text-shadow: 0 1px 0 rgba(255,255,255,.7);
|
||||
}
|
||||
.quickSetupBar { margin-bottom: 6px; }
|
||||
.settingsCard .segmented { margin-top: 2px; }
|
||||
.markerRow {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.markerChip {
|
||||
border: 2px dashed rgba(36,48,68,.25);
|
||||
background: #fff6df;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.summonCard {
|
||||
background: linear-gradient(180deg, #fffdf5, #fff2d7);
|
||||
}
|
||||
.finishActions {
|
||||
display: grid;
|
||||
grid-template-columns: 1.35fr .8fr .6fr;
|
||||
}
|
||||
.bigPrimary {
|
||||
min-height: 48px;
|
||||
font-size: 14px;
|
||||
background: linear-gradient(180deg, #89e4ae, #65d596) !important;
|
||||
}
|
||||
.sideSwitcher button:first-child.active::after,
|
||||
.sideSwitcher button:nth-child(2).active::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
}
|
||||
.spawnPop {
|
||||
pointer-events: none;
|
||||
}
|
||||
.selectedAsset { font-weight: 900; padding-bottom: 8px; border-bottom: 2px dashed rgba(45,60,80,.25); }
|
||||
@media (max-width: 720px) {
|
||||
.toolHud { top: auto; bottom: 126px; right: 10px; }
|
||||
.finishActions { grid-template-columns: 1fr; }
|
||||
.paletteGrid { grid-template-columns: repeat(8, 1fr); }
|
||||
}
|
||||
|
||||
.paletteGrid button { width: 100%; aspect-ratio: 1 / 1; padding: 0; min-height: 22px; }
|
||||
.sideSwitcher button { white-space: nowrap; }
|
||||
.heroEditor .hint { line-height: 1.35; }
|
||||
|
||||
|
||||
.compactEditorTop { margin-bottom: 4px; min-height: 0; }
|
||||
body, button, input, select, textarea { font-smooth: never; -webkit-font-smoothing: none; }
|
||||
|
||||
/* latest layout fixes */
|
||||
.studioDrawer { width: min(600px, calc(100vw - 24px)); }
|
||||
.paintLayout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 360px) 1fr;
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
#paintCanvas {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
}
|
||||
.paletteGrid {
|
||||
margin: 0;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
align-content: start;
|
||||
gap: 4px;
|
||||
max-height: 360px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.paletteSwatch, .paletteGrid button {
|
||||
min-height: 20px;
|
||||
}
|
||||
.sideSwitcher { justify-content: flex-start; }
|
||||
.authorCard span { white-space: nowrap; }
|
||||
@media (max-width: 720px) {
|
||||
.paintLayout { grid-template-columns: 1fr; }
|
||||
.paletteGrid { grid-template-columns: repeat(10, 1fr); max-height: none; }
|
||||
}
|
||||
|
||||
/* Selection bubble + outline controls */
|
||||
.palettePanel { display: grid; gap: 8px; }
|
||||
.selectionBubble {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 9;
|
||||
min-width: 126px;
|
||||
max-width: 210px;
|
||||
padding: 8px;
|
||||
background: #fffdf5;
|
||||
border: 3px solid var(--line);
|
||||
box-shadow: 4px 4px 0 rgba(36,48,68,.20);
|
||||
pointer-events: auto;
|
||||
text-align: center;
|
||||
}
|
||||
.selectionBubble::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -10px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #fffdf5;
|
||||
border-right: 3px solid var(--line);
|
||||
border-bottom: 3px solid var(--line);
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
}
|
||||
.bubbleName {
|
||||
font-size: 12px;
|
||||
font-weight: 950;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
.bubbleAuthor {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
font-weight: 800;
|
||||
}
|
||||
.bubbleVotes {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.bubbleVotes button {
|
||||
padding: 3px 7px;
|
||||
min-width: 30px;
|
||||
font-size: 11px;
|
||||
}
|
||||
#voteScore {
|
||||
font-weight: 950;
|
||||
font-size: 12px;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.bubbleRemixFrom, .bubbleRemixCount {
|
||||
margin-top: 3px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.bubbleActions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.bubbleActions button {
|
||||
padding: 4px 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.bubbleVotes button.mutedVote, .assetActions button.mutedAction {
|
||||
opacity: .45;
|
||||
}
|
||||
.bubbleVotes button.activeVote {
|
||||
background: var(--sun);
|
||||
}
|
||||
|
||||
/* Pixel-ish UI refinements */
|
||||
body, button, input, select, textarea {
|
||||
font-family: "Courier New", "Lucida Console", Monaco, monospace;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
button, .tab, .tool, .iconButton, .cardTitle, .logo {
|
||||
text-transform: uppercase;
|
||||
font-weight: 900;
|
||||
}
|
||||
.libraryHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.hiddenAssetPanel {
|
||||
border: 2px dashed rgba(36,48,68,.35);
|
||||
background: #fff3d6;
|
||||
padding: 8px;
|
||||
}
|
||||
.compactAssetList .assetCard { opacity: .82; }
|
||||
.librarySectionTitle {
|
||||
margin: 12px 0 6px;
|
||||
padding: 5px 7px;
|
||||
background: #ffe6f0;
|
||||
border: 2px solid var(--line);
|
||||
box-shadow: 3px 3px 0 rgba(36,48,68,.12);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.advancedRow {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.advancedOnly.active { background: #bde9ff; }
|
||||
.depthLegend {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Simplified current UI */
|
||||
body, button, input, select, textarea {
|
||||
font-family: "MS Gothic", "Osaka-Mono", "DotumChe", "Courier New", ui-monospace, monospace;
|
||||
letter-spacing: .02em;
|
||||
-webkit-font-smoothing: none;
|
||||
text-rendering: geometricPrecision;
|
||||
}
|
||||
.toolRow { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
.editorHistoryRow { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.advancedRow {
|
||||
grid-template-columns: max-content max-content max-content 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
.librarySectionTitle {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
margin: 10px 0 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.librarySectionTitle::after {
|
||||
content: " click: genre";
|
||||
float: right;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.assetSectionGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.libraryEmptyNote {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
border: 2px dashed rgba(36,48,68,.2);
|
||||
padding: 8px;
|
||||
background: #fff9e8;
|
||||
}
|
||||
.assetSectionGrid .assetCard {
|
||||
grid-template-columns: 54px 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.assetSectionGrid .assetPreview {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
.assetSectionGrid .assetActions button { padding: 4px 5px; font-size: 10px; }
|
||||
@media (max-width: 720px) {
|
||||
.assetSectionGrid { grid-template-columns: 1fr; max-height: none; }
|
||||
.toolRow { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.editorHistoryRow { grid-template-columns: 1fr 1fr; }
|
||||
.advancedRow { grid-template-columns: 1fr 1fr 1fr; }
|
||||
}
|
||||
|
||||
|
||||
.syncStats {
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(20, 25, 32, 0.06);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue