This commit is contained in:
33333-33333 2026-06-02 16:26:44 +09:00
commit a05c031074
7 changed files with 1781 additions and 385 deletions

View file

@ -9,7 +9,7 @@ Open `index.html` in a modern browser. No build step or third-party dependency i
## 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.
@ -161,12 +161,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 +187,85 @@ 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.

1245
app.js

File diff suppressed because it is too large Load diff

View file

@ -9,41 +9,44 @@
<body>
<div class="app">
<canvas id="worldCanvas" aria-label="Island map"></canvas>
<div id="islandClickHint" class="islandClickHint">Left-click: select · Right-click: clear</div>
<button id="openEditor" class="edgeAdd" title="Open Pixel Studio">+</button>
<button id="openEditor" class="drawDockButton" type="button" title="Open Pixel Studio">✎ DRAW</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">Save + Place creates a local ID, name, and password. Change the password after creation.</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,9 +58,9 @@
</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 devOnly" data-tab="data">Data</button>
</nav>
<div class="drawerBody">
@ -102,33 +105,49 @@
</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>
<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 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>
<button id="undoPaint" class="tool toolUndo" type="button" disabled>↶ Undo</button>
<button id="redoPaint" class="tool toolRedo" type="button" disabled>↷ Redo</button>
</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">
<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>
<button id="toggleAdvanced" class="tool toolAdvanced" type="button"> Advanced</button>
<button id="toolLight" class="tool toolLight advancedOnly" hidden>✹ Light</button>
<button id="toolDepth" class="tool toolDepth advancedOnly" hidden>▨ Depth</button>
<button id="toolParticle" class="tool toolParticle 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>
<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>
<span id="advancedHint" class="hint" hidden>Advanced tools stay below the basic draw row. Select tool: left-click selects, right-click clears selection. Particle paints emitter cells with palette color and direction. Shift/right-click clears a particle cell.</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>
@ -138,9 +157,7 @@
<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="saveAsset" class="secondary">Save to Collection</button>
</div>
</div>
<div id="lineageNote" class="lineageNote"></div>
@ -149,7 +166,7 @@
<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>
@ -162,10 +179,10 @@
</div>
</section>
<section id="tab-data" class="tabPanel">
<section id="tab-data" class="tabPanel devOnly">
<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>
<p class="hint">Works are durable collection works; 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>
@ -221,7 +238,11 @@
<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>

View file

@ -2,21 +2,134 @@
'use strict';
const root = window.PixelIslandModules ||= {};
const FORMAT = 'pixel-island-phase2-compact-v1';
const FORMAT = 'pixel-island-phase2-compact-v2';
const FORMAT_V1 = 'pixel-island-phase2-compact-v1';
const SNAPSHOT_FORMAT = 'pixel-island-phase2-snapshot-v1';
const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v1';
const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v2';
const ASSET_BUNDLE_FORMAT_V1 = 'pixel-island-phase2-asset-bundle-v1';
const EVENT_LOG_LIMIT = 300;
const DB_NAME = 'pixel-island-phase2-cache';
const DB_VERSION = 1;
const ASSET_STORE = 'assets';
const SNAPSHOT_STORE = 'snapshots';
const COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const PACK6_CHARS = `.${COLOR_CODES}`;
function isCompactState(value) {
return Boolean(value && value.format === FORMAT && Array.isArray(value.assets));
return Boolean(value && (value.format === FORMAT || value.format === FORMAT_V1) && Array.isArray(value.assets));
}
function isAssetBundle(value) {
return Boolean(value && value.format === ASSET_BUNDLE_FORMAT && Array.isArray(value.assets));
return Boolean(value && (value.format === ASSET_BUNDLE_FORMAT || value.format === ASSET_BUNDLE_FORMAT_V1) && Array.isArray(value.assets));
}
function bytesToBase64(bytes) {
if (!bytes || !bytes.length) return '';
if (typeof btoa === 'function') {
let binary = '';
const chunk = 8192;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.slice(i, i + chunk));
}
return btoa(binary);
}
if (typeof Buffer !== 'undefined') return Buffer.from(bytes).toString('base64');
return '';
}
function base64ToBytes(value) {
const text = String(value || '');
if (!text) return [];
if (typeof atob === 'function') {
const binary = atob(text);
const out = new Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) & 255;
return out;
}
if (typeof Buffer !== 'undefined') return Array.from(Buffer.from(text, 'base64'));
return [];
}
function pack6Index(ch) {
const index = PACK6_CHARS.indexOf(ch || '.');
return index >= 0 && index < 64 ? index : 0;
}
function unpack6Index(value) {
return PACK6_CHARS[value] || '.';
}
function canPack6(text) {
for (const ch of String(text || '')) if (PACK6_CHARS.indexOf(ch) < 0) return false;
return true;
}
function pack6Text(input) {
const text = String(input || '');
if (!text) return '';
let buffer = 0;
let bitCount = 0;
const bytes = [];
for (const ch of text) {
buffer = (buffer << 6) | pack6Index(ch);
bitCount += 6;
while (bitCount >= 8) {
bitCount -= 8;
bytes.push((buffer >> bitCount) & 255);
buffer &= (1 << bitCount) - 1;
}
}
if (bitCount > 0) bytes.push((buffer << (8 - bitCount)) & 255);
return bytesToBase64(bytes);
}
function unpack6Text(input, length) {
const target = Math.max(0, Number(length) || 0);
if (!target) return '';
const bytes = base64ToBytes(input);
let buffer = 0;
let bitCount = 0;
let out = '';
for (const byte of bytes) {
buffer = (buffer << 8) | (byte & 255);
bitCount += 8;
while (bitCount >= 6 && out.length < target) {
bitCount -= 6;
out += unpack6Index((buffer >> bitCount) & 63);
buffer &= (1 << bitCount) - 1;
}
if (out.length >= target) break;
}
return (out + '.'.repeat(target)).slice(0, target);
}
function packBitMask(mask) {
const bits = Array.from(mask || [], Boolean);
if (!bits.length) return '';
const bytes = [];
let current = 0;
for (let i = 0; i < bits.length; i++) {
current = (current << 1) | (bits[i] ? 1 : 0);
if (i % 8 === 7) {
bytes.push(current & 255);
current = 0;
}
}
const rest = bits.length % 8;
if (rest) bytes.push((current << (8 - rest)) & 255);
return bytesToBase64(bytes);
}
function unpackBitMask(input, length) {
const target = Math.max(0, Number(length) || 0);
const bytes = base64ToBytes(input);
const out = [];
for (const byte of bytes) {
for (let bit = 7; bit >= 0 && out.length < target; bit--) out.push(Boolean((byte >> bit) & 1));
if (out.length >= target) break;
}
while (out.length < target) out.push(false);
return out;
}
function rleEncode(input) {
@ -61,7 +174,17 @@
return (source + emptyChar.repeat(total)).slice(0, total);
}
function cropPlane(encoded, size, emptyChar = '.') {
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, size, emptyChar = '.', options = {}) {
const text = normalizeEncodedPlane(encoded, size, emptyChar);
let minX = size;
let minY = size;
@ -81,42 +204,55 @@
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 * size + minX, y * size + 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 = '.') {
function expandPlane(packed, size, emptyChar = '.', baseEncoded = null) {
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 || '');
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 < size && dy < size && src < value.length) out[dy * size + dx] = value[src] || emptyChar;
}
}
return out.join('');
}
function packAsset(asset) {
function planeForAsset(asset) {
const size = Math.max(1, Number(asset?.size) || 16);
return normalizeEncodedPlane(asset?.faces?.right || asset?.pixels || '', size, '.');
}
function selectPixelPlanePack(asset, assetMap) {
const size = Math.max(1, Number(asset.size) || 16);
const right = planeForAsset(asset);
return cropPlane(right, size, '.', { bitPack: true });
}
function packAsset(asset, assetMap = null) {
const size = Math.max(1, Number(asset.size) || 16);
const category = asset.category === 'dynamic' ? 'dynamic' : 'static';
const right = normalizeEncodedPlane(asset.faces?.right || asset.pixels || '', size, '.');
const depth = asset.meta?.depthPixels ? normalizeEncodedPlane(asset.meta.depthPixels, size, '.') : '';
const lights = Array.isArray(asset.meta?.lightPixels)
? asset.meta.lightPixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || asset.meta?.lightColor || '']).filter((p) => p[2])
: [];
const particles = Array.isArray(asset.meta?.particlePixels)
? asset.meta.particlePixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || '']).filter((p) => p[2])
? 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, '.');
@ -124,6 +260,10 @@
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,7 +272,7 @@
c: category,
t: asset.subtype || (category === 'dynamic' ? 'human' : 'other'),
s: size,
p: cropPlane(right, size, '.'),
p: selectPixelPlanePack(asset, assetMap),
f: category === 'dynamic' ? { l: 'mirror' } : null,
pa: asset.parentAssetId || null,
oa: asset.originalAssetId || null,
@ -143,24 +283,29 @@
};
}
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 category = packed.c === 'dynamic' || packed.category === 'dynamic' ? 'dynamic' : 'static';
const pixels = expandPlane(packed.p, size, '.');
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,
particleConfig,
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
};
@ -182,6 +327,10 @@
};
}
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;
@ -208,12 +357,18 @@
return { id: row[0], assetId: row[1], homeX: row[2], homeY: row[3], createdAt: row[4], version: row[5] || 1, publishedAt: meta.pu || row[4], status: meta.st || 'active' };
}
function 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 || {},
@ -233,9 +388,9 @@
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 || {},
@ -253,7 +408,7 @@
}
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 +421,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, 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 +439,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 +462,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 +474,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 +560,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 +577,24 @@
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,
compactState,
expandState,
compactSizeReport,
@ -419,8 +605,10 @@
findMissingAssetIds,
makeEvent,
createAssetUpsertEvent,
createAssetDeleteEvent,
createObjectUpsertEvent,
createObjectDeleteEvent,
deleteAssetOnly,
applyEvent,
cacheAssets,
readCachedAssets,

View file

@ -1015,3 +1015,447 @@ body, button, input, select, textarea { font-size: 15px; }
.likedCodexItem canvas { width:28px; height:28px; image-rendering:pixelated; }
.hiddenReasonText { color:#6f2f3b; font-weight:700; }
.exhibitionNote { padding:8px 10px; background:#fff3d9; border:2px solid rgba(36,48,68,.2); margin-bottom:8px; font-size:14px; line-height:1.45; }
/* compact account bar / remove right-side HUD */
.toolHud { display: none !important; }
.topHud { min-height: 50px; padding: 8px 12px; gap: 10px; align-items: center; }
.authorCard {
width: auto !important;
min-width: 0 !important;
max-width: min(520px, 48vw);
display: flex !important;
align-items: center;
gap: 8px;
padding: 5px 7px !important;
white-space: nowrap;
}
.authorCard span { display:inline !important; margin:0; font-size:11px !important; }
.authorCard input { margin-top:0 !important; width:132px; min-height:28px; }
.authorCard small { display:inline !important; margin-top:0 !important; line-height:1.15 !important; font-size:11px !important; max-width:240px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.miniButton { margin-top:0 !important; padding:5px 7px !important; font-size:11px !important; white-space:nowrap; }
.clockCard { width: 190px; padding: 6px 8px; }
.subline { white-space: nowrap; }
.advancedRow { grid-template-columns: repeat(5, max-content) 1fr !important; }
@media (max-width: 900px) {
.topHud { right: 76px; align-items:flex-start; }
.authorCard { max-width: calc(100vw - 110px); flex-wrap: wrap; white-space: normal; }
.authorCard small { max-width: 100%; }
}
/* 2026 polish pass: make the island the visual default, keep creation and collection one click away. */
.islandTopBar {
top: 12px;
left: 50%;
right: auto;
width: min(560px, calc(100vw - 24px));
min-height: 0 !important;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px !important;
background: rgba(255, 253, 245, .78) !important;
border: 1px solid rgba(36, 48, 68, .24) !important;
box-shadow: 0 6px 18px rgba(22, 31, 46, .12) !important;
backdrop-filter: blur(10px);
pointer-events: auto;
}
.brandBlock { min-width: 0; }
.islandTopBar .logo { font-size: 18px; line-height: 1; white-space: nowrap; }
.islandTopBar .subline {
margin-top: 2px;
max-width: 300px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 11px;
opacity: .72;
}
.topActions { display: flex; gap: 6px; align-items: center; }
.topButton {
padding: 7px 10px !important;
border-width: 1px !important;
box-shadow: none !important;
background: rgba(255, 253, 245, .86) !important;
font-size: 12px !important;
}
.topButton.primary { background: var(--mint) !important; }
.utilityControl { display: none !important; }
.edgeAdd { display: none !important; }
.devOnly { display: none !important; }
/* Beginner-first editor: show only Pencil/Erase/Fill/Pick + Undo/Redo until Advanced is enabled. */
.advancedTool { display: none !important; }
.studioDrawer.advancedTools .advancedTool { display: inline-flex !important; }
.studioDrawer.advancedTools .toolRow .advancedTool { display: block !important; }
.editorToolRow { grid-template-columns: repeat(4, minmax(0, 1fr)) !important; }
.editorHistoryRow { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
.studioDrawer.advancedTools .editorToolRow { grid-template-columns: repeat(5, minmax(0, 1fr)) !important; }
.studioDrawer.advancedTools .editorHistoryRow { grid-template-columns: repeat(6, minmax(0, 1fr)) !important; }
#editHint { opacity: .78; }
#editHint::after { content: " Advanced reveals line, rectangle, selection, flip, light, depth, and particles."; }
/* Let the island breathe under the lighter navigation. */
#worldCanvas { background: #8dd7ff; }
.selectionBubble {
border-width: 2px !important;
box-shadow: 0 8px 20px rgba(22,31,46,.16) !important;
background: rgba(255,253,245,.94) !important;
}
@media (max-width: 720px) {
.islandTopBar { width: calc(100vw - 16px); top: 8px; }
.islandTopBar .subline { display: none; }
.topButton { padding: 7px 8px !important; }
}
/* v8 account, editor, and placement refinements */
.islandClickHint {
position: fixed;
left: 14px;
bottom: 14px;
z-index: 24;
padding: 6px 8px;
border: 1px solid rgba(36,48,68,.22);
background: rgba(255,253,245,.72);
box-shadow: 0 5px 16px rgba(22,31,46,.10);
backdrop-filter: blur(8px);
font-size: 11px;
color: rgba(36,48,68,.78);
pointer-events: none;
}
.islandTopBar {
width: min(960px, calc(100vw - 24px)) !important;
}
.accountCard.utilityControl {
display: flex !important;
align-items: center;
gap: 7px;
max-width: 430px !important;
padding: 5px 7px !important;
border: 1px solid rgba(36,48,68,.2) !important;
background: rgba(255,253,245,.68) !important;
box-shadow: none !important;
}
.accountFields {
display: grid;
grid-template-columns: 96px 112px 112px;
gap: 5px;
align-items: end;
}
.accountField {
display: grid;
gap: 2px;
font-size: 9px !important;
line-height: 1;
color: rgba(36,48,68,.72);
}
.accountField input {
width: 100% !important;
min-height: 24px !important;
padding: 3px 5px !important;
font-size: 10px !important;
border-width: 1px !important;
background: rgba(255,255,255,.72);
}
.accountField input[readonly] { opacity: .72; }
.accountCard small {
max-width: 118px !important;
white-space: normal !important;
font-size: 9px !important;
line-height: 1.15 !important;
opacity: .78;
}
.accountCard .miniButton {
font-size: 9px !important;
padding: 4px 5px !important;
border-width: 1px !important;
box-shadow: none !important;
}
.clockCard.utilityControl { display: none !important; }
.editorToolRow { grid-template-columns: repeat(4, minmax(0, 1fr)) !important; }
.editorHistoryRow { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
.studioDrawer.advancedTools .editorToolRow { grid-template-columns: repeat(4, minmax(0, 1fr)) !important; }
.studioDrawer.advancedTools .editorHistoryRow { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
.advancedToolGroup {
display: grid;
gap: 8px;
margin: 2px 0 8px;
}
.advancedToolGroup[hidden] { display: none !important; }
.advancedDrawRow { grid-template-columns: repeat(5, minmax(0, 1fr)) !important; }
.advancedActionRow { grid-template-columns: repeat(4, minmax(0, 1fr)) !important; }
.studioDrawer.advancedTools .advancedTool { display: block !important; }
.toolBrush { background: #e8fff0 !important; }
.toolErase { background: #ffe9e2 !important; }
.toolFill { background: #e7f0ff !important; }
.toolPick { background: #fff0d4 !important; }
.toolUndo, .toolRedo { background: #f2ecff !important; }
.toolLine { background: #e9f7ff !important; }
.toolRect { background: #edf9e3 !important; }
.toolSelect { background: #ffe8f3 !important; }
.toolDoor { background: #f5ead7 !important; }
.toolOutline { background: #fff6d8 !important; }
.toolFlipH, .toolFlipV { background: #eaf2ff !important; }
.toolClearSel { background: #f7e9ff !important; }
.toolAdvanced { background: #f5f1e7 !important; }
.toolLight { background: #fff0a8 !important; }
.toolDepth { background: #dfeeff !important; }
.toolParticle { background: #e8fffb !important; }
.tool.active, button.active {
outline: 2px solid rgba(36,48,68,.55);
outline-offset: -4px;
filter: saturate(1.08);
}
.particleControl {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 7px;
border: 2px solid var(--line);
background: #e8fffb;
box-shadow: 3px 3px 0 rgba(36,48,68,.14);
font-size: 11px;
font-weight: 900;
text-transform: uppercase;
}
.particleControl[hidden] { display: none !important; }
.particleControl select {
min-height: 26px;
padding: 2px 5px;
font-size: 11px;
}
.finishActions { grid-template-columns: 1.4fr 1fr !important; }
.placementPreviewBar span::after { content: " Left-click chooses a tile; right-click cancels selection."; }
@media (max-width: 980px) {
.islandTopBar { width: calc(100vw - 16px) !important; flex-wrap: wrap; justify-content: center; }
.accountCard.utilityControl { order: 3; max-width: 100% !important; }
.accountFields { grid-template-columns: repeat(3, minmax(72px, 1fr)); }
.accountCard small { max-width: 100% !important; }
}
@media (max-width: 560px) {
.accountFields { grid-template-columns: 1fr; }
.finishActions { grid-template-columns: 1fr !important; }
.advancedDrawRow, .advancedActionRow { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
}
/* v9 menu and particle-range refinements */
.cuteMenuButton {
flex: 0 0 auto;
width: 38px;
height: 38px;
padding: 0 !important;
display: grid;
place-items: center;
border: 2px solid rgba(36,48,68,.72) !important;
border-radius: 14px !important;
background: linear-gradient(180deg, #ffe3f0, #ffc3dd) !important;
box-shadow: 3px 3px 0 rgba(36,48,68,.18) !important;
font-size: 19px !important;
line-height: 1 !important;
cursor: pointer;
}
.cuteMenuButton:hover { transform: translateY(-1px); filter: saturate(1.08); }
.cuteMenuButton:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 rgba(36,48,68,.18) !important; }
.islandTopBar .brandBlock { margin-right: auto; }
.particleControlGroup {
display: grid !important;
grid-template-columns: minmax(92px, 120px) auto;
align-items: end;
gap: 6px 8px;
max-width: min(100%, 360px);
text-transform: none;
}
.particleControlGroup label { font-size: 10px; font-weight: 900; color: rgba(36,48,68,.76); }
.particleRangeButtons {
display: grid;
grid-template-columns: repeat(2, minmax(74px, 1fr));
gap: 5px;
}
.particleRangeButtons .tool { padding: 6px 7px !important; font-size: 11px !important; }
#particleRangeStatus {
grid-column: 1 / -1;
font-size: 10px;
color: rgba(36,48,68,.72);
line-height: 1.25;
}
@media (max-width: 560px) {
.particleControlGroup { grid-template-columns: 1fr; }
.particleRangeButtons { grid-template-columns: 1fr 1fr; }
}
/* v10 bottom DRAW access and remix bottom sheet */
.drawDockButton {
position: fixed;
z-index: 28;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
min-width: 210px;
min-height: 62px;
padding: 12px 28px !important;
border: 4px solid var(--line) !important;
border-radius: 22px !important;
background: linear-gradient(180deg, #ffe18f, #ff9dbf 62%, #ff7aa8) !important;
color: #253044 !important;
box-shadow: 0 8px 0 rgba(36,48,68,.24), 0 14px 26px rgba(22,31,46,.18) !important;
font-size: 24px !important;
letter-spacing: .06em;
line-height: 1 !important;
cursor: pointer;
}
.drawDockButton:hover { filter: saturate(1.08) brightness(1.02); }
.drawDockButton:active { transform: translate(-50%, 4px); box-shadow: 0 4px 0 rgba(36,48,68,.24), 0 8px 18px rgba(22,31,46,.16) !important; }
.drawDockButton[hidden] { display: none !important; }
.islandTopBar .cuteMenuButton { display: none !important; }
.islandTopBar .brandBlock { margin-right: 0 !important; }
.islandClickHint { bottom: 96px !important; }
.selectionBubble {
position: absolute !important;
left: 0 !important;
top: 0 !important;
z-index: 36 !important;
min-width: 138px !important;
max-width: 224px !important;
padding: 8px 9px !important;
display: block !important;
text-align: center !important;
border: 3px solid var(--line) !important;
border-radius: 14px !important;
background: rgba(255,253,245,.98) !important;
box-shadow: 0 6px 0 rgba(36,48,68,.18), 0 10px 20px rgba(22,31,46,.15) !important;
}
.selectionBubble[hidden] { display: none !important; }
.selectionBubble::after {
display: block !important;
content: '' !important;
position: absolute;
left: 50%;
bottom: -10px;
width: 14px;
height: 14px;
background: rgba(255,253,245,.98);
border-right: 3px solid var(--line);
border-bottom: 3px solid var(--line);
transform: translateX(-50%) rotate(45deg);
}
.bubbleName { font-size: 13px !important; }
.bubbleAuthor, .bubbleRemixFrom, .bubbleRemixCount { font-size: 10px !important; margin-top: 2px !important; }
.bubbleVotes { margin-top: 6px !important; grid-template-columns: 1fr auto 1fr !important; justify-content: center; }
.bubbleActions {
display: grid !important;
grid-template-columns: 1fr 1fr !important;
gap: 5px !important;
margin-top: 6px !important;
}
.bubbleActions button { min-height: 32px; font-size: 10px !important; }
.bubbleRemixPrimary {
background: linear-gradient(180deg, #ffb3d1, #ff7daf) !important;
border-width: 3px !important;
}
.bubbleMenuButton {
margin-top: 6px;
min-width: 64px;
min-height: 28px;
padding: 5px 8px !important;
border-radius: 12px !important;
background: #fff2d7 !important;
font-size: 10px !important;
}
.bubbleMenuActions {
display: flex;
justify-content: center;
gap: 6px;
padding-top: 4px;
margin-top: 4px;
border-top: 1px dashed rgba(36,48,68,.25);
}
.bubbleMenuActions[hidden] { display: none !important; }
.bubbleMenuActions button { font-size: 10px !important; padding: 5px 8px !important; }
@media (max-width: 760px) {
.drawDockButton { min-width: 176px; min-height: 56px; font-size: 20px !important; bottom: 16px; }
.islandClickHint { display: none; }
}
/* v15 analog day-night clock */
.analogClock {
--clock-rotate: 0deg;
position: fixed;
top: clamp(104px, 14vh, 158px);
right: 18px;
z-index: 28;
width: 82px;
height: 100px;
display: grid;
place-items: center;
gap: 4px;
pointer-events: none;
}
.analogClockDial {
position: relative;
width: 72px;
height: 72px;
border: 4px solid var(--line);
border-radius: 50%;
background:
radial-gradient(circle at 50% 50%, rgba(255,255,245,.42) 0 6%, rgba(255,255,245,0) 7%),
conic-gradient(from -90deg,
#142147 0deg 54deg,
#5468aa 70deg,
#f5b067 96deg,
#8bd8ff 128deg 232deg,
#ff9d4f 266deg,
#c84f2e 292deg,
#1b2a58 326deg 360deg);
box-shadow: 0 5px 0 rgba(36,48,68,.20), 0 8px 18px rgba(18,28,44,.18);
image-rendering: pixelated;
}
.analogClockDial::after {
content: '';
position: absolute;
inset: 8px;
border: 1px dashed rgba(36,48,68,.25);
border-radius: 50%;
}
.analogClockHand {
position: absolute;
left: 50%;
top: 50%;
width: 4px;
height: 28px;
transform-origin: 50% 100%;
transform: translate(-50%, -100%) rotate(var(--clock-rotate));
background: #243044;
border-radius: 6px 6px 2px 2px;
box-shadow: 1px 0 0 rgba(255,255,255,.45);
}
.analogClockHand::after {
content: '';
position: absolute;
left: 50%;
bottom: -5px;
width: 10px;
height: 10px;
transform: translateX(-50%);
border: 3px solid var(--line);
background: #fff7e8;
border-radius: 50%;
}
.analogClockLabel {
min-width: 70px;
padding: 2px 7px;
border: 2px solid var(--line);
border-radius: 999px;
background: rgba(255,248,232,.9);
box-shadow: 0 3px 0 rgba(36,48,68,.16);
text-align: center;
font-size: 10px;
font-weight: 950;
}
.analogPhaseBar { display: none !important; }
@media (max-width: 760px) {
.analogClock { top: 82px; right: 10px; transform: scale(.86); transform-origin: top right; }
}