updateeeed

This commit is contained in:
33333-33333 2026-06-01 22:48:15 +09:00
commit 5b5848f1dc
11 changed files with 7269 additions and 208 deletions

View file

@ -1,17 +1,69 @@
# Pixel Island Summoner
Local Python rewrite of the original JavaScript browser prototype.
Browser-only prototype for a shared pixel-art island.
## Run
```bash
python app.py
```
Open `index.html` in a modern browser. No build step or third-party dependency is required.
The app uses Python's built-in Tkinter UI toolkit, so there are no third-party dependencies.
## Current prototype scope
## Notes
- 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.
- Local data is saved to `pixel_island_save.json`.
- You can draw pixel assets, save them to the library, summon them onto the island, erase placed objects, and export or import JSON save data.
- Mouse wheel zooms the island. In Pan / Inspect mode, drag the island to move the camera.
## Phase 1 changes
- Asset data is normalized to schema 2+ compatible shape.
- Dynamic left-facing pixels are not stored; `faces.left` is represented as `mirror`.
- Empty depth/light metadata is omitted or normalized.
- Editor tools include Draw, Erase, Fill, Pick, Undo, and Redo.
## Phase 2 changes
- Local save uses a compact Phase 2 envelope in `localStorage`.
- Pixel planes are bbox-cropped and RLE-compressed when this is smaller than raw cropped data.
- Compact asset bundles can be exported separately from world snapshots.
- World snapshots contain placements, dynamic summons, and an asset manifest rather than full asset blobs.
- Local object/asset mutations append small sync events to `eventLog`.
- IndexedDB asset/snapshot cache helpers are included for future server-backed missing-asset fetches.
## Phase 3 changes
- Terrain rendering is chunk-cached instead of using one large terrain canvas.
- Only terrain chunks intersecting the current viewport are drawn.
- Static and dynamic objects outside the viewport margin are culled before sprite generation/sorting.
- Dynamic runtime updates are stepped at 15 Hz and capped per frame to avoid spiral-of-death on slow devices.
- Rendering pauses while the browser tab is hidden and resumes on visibility change.
- Tile/object lookups use a small spatial index for inspect, erase, and nearby-target searches.
- Data tab reports visible terrain chunks for quick performance inspection.
## Phase 4 changes
- Editor schema is now 5.
- Added Line and Rect tools. Rect supports filled rectangles with Shift; right-click erases line/rect cells.
- Added Select tool. Drag to select a rectangular area, then drag the selection or use arrow keys/buttons to move it.
- Nudge buttons move the active selection; without a selection they shift the whole drawing.
- Added horizontal/vertical flip, palette-colored outline generation, and clear selection.
- Added editor PNG export/import. PNG import quantizes the image to the current island palette and current canvas size.
- Added keyboard shortcuts: B/E/F/I/L/R/S for tools, Ctrl/Cmd+Z/Y for history, arrows for moving selected pixels, Escape to clear selection.
## Export formats
- **Export full JSON**: human-readable full local state.
- **Export compact**: compact local state suitable for saving/transferring.
- **Export snapshot**: placement state plus asset manifest; requires asset bundles or cache to render fully.
- **Export asset bundle**: compact asset blob list for missing-asset transfer.
## Phase 4 follow-up adjustments
- PNG import now chooses the editor canvas size from the image size. Square 8/16/32/64 PNGs import at their native size; other sizes are fit into the nearest supported square canvas.
- Depth is now three-state: high, normal, and low. High brightens sprite pixels; low darkens them.
- Right-clicking the world canvas clears the current map selection instead of moving or placing the selected asset.
- Dynamic sprites now pause intermittently instead of walking continuously.
- Night rendering is darker.
- Object picking now uses sprite-sized, per-opaque-pixel hit testing, so transparent pixels are not clickable.
- The CSS font stack now tries `3x5 MT Pixel Font` first. The font file itself is not bundled.

58
README.md.bak Normal file
View file

@ -0,0 +1,58 @@
# Pixel Island Summoner
Browser-only prototype for a shared pixel-art island.
## Run
Open `index.html` in a modern browser. No build step or third-party dependency is required.
## Current prototype scope
- Draw pixel assets in the in-browser editor.
- Save assets to the local library.
- Place static assets and summon dynamic assets onto the island.
- Dynamic assets default to right-facing on spawn; left-facing is rendered by mirroring during movement only.
- Local voting, hiding, remix/copy edit, and import/export tools are included.
## Phase 1 changes
- Asset data is normalized to schema 2+ compatible shape.
- Dynamic left-facing pixels are not stored; `faces.left` is represented as `mirror`.
- Empty depth/light metadata is omitted or normalized.
- Editor tools include Draw, Erase, Fill, Pick, Undo, and Redo.
## Phase 2 changes
- Local save uses a compact Phase 2 envelope in `localStorage`.
- Pixel planes are bbox-cropped and RLE-compressed when this is smaller than raw cropped data.
- Compact asset bundles can be exported separately from world snapshots.
- World snapshots contain placements, dynamic summons, and an asset manifest rather than full asset blobs.
- Local object/asset mutations append small sync events to `eventLog`.
- IndexedDB asset/snapshot cache helpers are included for future server-backed missing-asset fetches.
## Phase 3 changes
- Terrain rendering is chunk-cached instead of using one large terrain canvas.
- Only terrain chunks intersecting the current viewport are drawn.
- Static and dynamic objects outside the viewport margin are culled before sprite generation/sorting.
- Dynamic runtime updates are stepped at 15 Hz and capped per frame to avoid spiral-of-death on slow devices.
- Rendering pauses while the browser tab is hidden and resumes on visibility change.
- Tile/object lookups use a small spatial index for inspect, erase, and nearby-target searches.
- Data tab reports visible terrain chunks for quick performance inspection.
## Phase 4 changes
- Editor schema is now 5.
- Added Line and Rect tools. Rect supports filled rectangles with Shift; right-click erases line/rect cells.
- Added Select tool. Drag to select a rectangular area, then drag the selection or use arrow keys/buttons to move it.
- Nudge buttons move the active selection; without a selection they shift the whole drawing.
- Added horizontal/vertical flip, palette-colored outline generation, and clear selection.
- Added editor PNG export/import. PNG import quantizes the image to the current island palette and current canvas size.
- Added keyboard shortcuts: B/E/F/I/L/R/S for tools, Ctrl/Cmd+Z/Y for history, arrows for moving selected pixels, Escape to clear selection.
## Export formats
- **Export full JSON**: human-readable full local state.
- **Export compact**: compact local state suitable for saving/transferring.
- **Export snapshot**: placement state plus asset manifest; requires asset bundles or cache to render fully.
- **Export asset bundle**: compact asset blob list for missing-asset transfer.

1571
app.js

File diff suppressed because it is too large Load diff

3774
app.js.bak Normal file

File diff suppressed because it is too large Load diff

View file

@ -93,20 +93,47 @@
</div>
</div>
<div class="toolRow">
<button id="toolBrush" class="tool active">Draw</button>
<button id="toolErase" class="tool">Erase</button>
<div class="toolRow editorToolRow">
<button id="toolBrush" class="tool active" title="B">Draw</button>
<button id="toolErase" class="tool" title="E">Erase</button>
<button id="toolFill" class="tool" title="F">Fill</button>
<button id="toolPick" class="tool" title="I">Pick</button>
<button id="toolLine" class="tool" title="L">Line</button>
<button id="toolRect" class="tool" title="R">Rect</button>
<button id="toolSelect" class="tool" title="S">Select</button>
<button id="toolDoor" class="tool buildingOnly">Door</button>
<button id="clearPaint" class="tool danger">Clear</button>
</div>
<div class="toolRow 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>
<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/Depth tools are here. Depth supports High/Low/Clear. Left-drag paints the selected depth value; Shift clears.</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.</div>
<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">
@ -140,10 +167,14 @@
<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 JSON</button>
<button id="exportData">Export full JSON</button>
<button id="exportCompact">Export compact</button>
<button id="exportSnapshot">Export snapshot</button>
<button id="exportAssetBundle">Export asset bundle</button>
<button id="importData">Import JSON</button>
<button id="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>
@ -168,6 +199,8 @@
<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>

203
index.html.bak Normal file
View file

@ -0,0 +1,203 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pixel Island Summoner - Local Prototype</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div class="app">
<canvas id="worldCanvas" aria-label="Island map"></canvas>
<button id="openEditor" class="edgeAdd" title="Open Pixel Studio">+</button>
<header class="hud topHud">
<div>
<div class="logo">Pixel Island</div>
<div class="subline">Local prototype / Draw pixels, summon them to islands.</div>
</div>
<label class="authorCard">
<span>Your Name</span>
<input id="authorName" type="text" maxlength="24" value="Local Artist" />
</label>
<div class="clockCard" aria-label="world clock">
<div id="phaseLabel" class="phaseLabel">Day</div>
<div class="phaseBar"><span id="phaseBar"></span></div>
<div class="clockHint">10 min = 1 day</div>
</div>
</header>
<aside class="hud toolHud">
<button id="modeInspect" class="iconButton active" title="Pan / Inspect">Pan</button>
<button id="modePlace" class="iconButton" title="Summon selected asset">Place</button>
<button id="modeErase" class="iconButton danger" title="Remove asset">Erase</button>
<div class="divider"></div>
<button id="zoomOut" class="iconButton" title="Zoom out"></button>
<button id="zoomIn" class="iconButton" title="Zoom in">+</button>
<button id="resetView" class="iconButton" title="Reset view">Reset</button>
</aside>
<section id="studioDrawer" class="studioDrawer" aria-label="Pixel Studio">
<div class="drawerHead">
<div>
<h1>Pixel Studio</h1>
<p>Make static scenery or moving island life.</p>
</div>
<button id="closeEditor" class="ghostButton" title="Close">×</button>
</div>
<nav class="tabs" aria-label="Studio tabs">
<button class="tab active" data-tab="draw">Draw</button>
<button class="tab" data-tab="library">Library</button>
<button class="tab" data-tab="data">Data</button>
</nav>
<div class="drawerBody">
<section id="tab-draw" class="tabPanel active">
<div class="card editorCard heroEditor">
<div class="quickSetupBar twoCols">
<label class="field">Canvas size
<select id="assetSize">
<option value="8" selected>8×8</option>
<option value="16">16×16</option>
<option value="32">32×32</option>
<option value="64">64×64</option>
</select>
</label>
<label class="field">Role
<select id="assetCategory">
<option value="human">Human</option>
<option value="animal">Animal</option>
<option value="nature" selected>Nature</option>
<option value="building">Building</option>
<option value="other">Other</option>
</select>
</label>
</div>
<label class="field compactNameField">Name
<input id="assetName" type="text" maxlength="32" placeholder="Tiny bakery, round tree, island pup..." />
</label>
<div class="editorTop compactEditorTop">
<div id="sideSwitcher" class="sideSwitcher" hidden aria-label="Sprite direction">
<button id="editLeft" title="Left-facing sprite">◀ Left</button>
<button id="editRight" class="active" title="Right-facing sprite is the main/front direction">▶ Right</button>
</div>
</div>
<div class="paintLayout">
<canvas id="paintCanvas" width="512" height="512" aria-label="Pixel editor"></canvas>
<div class="palettePanel">
<div id="paletteGrid" class="paletteGrid" aria-label="Color palette"></div>
</div>
</div>
<div class="toolRow editorToolRow">
<button id="toolBrush" class="tool active" title="B">Draw</button>
<button id="toolErase" class="tool" title="E">Erase</button>
<button id="toolFill" class="tool" title="F">Fill</button>
<button id="toolPick" class="tool" title="I">Pick</button>
<button id="toolLine" class="tool" title="L">Line</button>
<button id="toolRect" class="tool" title="R">Rect</button>
<button id="toolSelect" class="tool" title="S">Select</button>
<button id="toolDoor" class="tool buildingOnly">Door</button>
<button id="clearPaint" class="tool danger">Clear</button>
</div>
<div class="toolRow editorHistoryRow">
<button id="undoPaint" class="tool" type="button" disabled>Undo</button>
<button id="redoPaint" class="tool" type="button" disabled>Redo</button>
<button id="outlinePaint" class="tool" type="button">Outline</button>
<button id="flipHorizontal" class="tool" type="button">Flip H</button>
<button id="flipVertical" class="tool" type="button">Flip V</button>
</div>
<div class="toolRow editorMoveRow">
<button id="nudgeLeft" class="tool" type="button"></button>
<button id="nudgeUp" class="tool" type="button"></button>
<button id="nudgeDown" class="tool" type="button"></button>
<button id="nudgeRight" class="tool" type="button"></button>
<button id="clearSelection" class="tool" type="button" disabled>Clear Sel</button>
</div>
<div class="toolRow editorFileRow">
<button id="exportPng" class="tool" type="button">Export PNG</button>
<button id="importPng" class="tool" type="button">Import PNG</button>
<input id="pngImportInput" type="file" accept="image/png,image/*" hidden />
</div>
<div class="advancedRow">
<button id="toggleAdvanced" class="tool" type="button">Advanced Draw</button>
<button id="toolLight" class="tool advancedOnly" hidden>Light</button>
<button id="toolDepth" class="tool advancedOnly" hidden>Depth</button>
<span id="advancedHint" class="hint" hidden>Light/Depth tools are here. Depth paints visible blue height marks; Shift/right-click erases.</span>
</div>
<input id="paintColor" type="color" value="#6bd06b" hidden />
<div id="editHint" class="hint">Palette color is used for pixels, lights, and depth marks. Shortcuts: B/E/F/I/L/R/S, Ctrl/Cmd+Z, arrows move a selection.</div>
</div>
<div class="card stack summonCard">
<div class="cardTitle">Finish</div>
<div class="actionRow finishActions">
<button id="saveAndPlace" class="primary bigPrimary">Save + Summon on Map</button>
<button id="saveAsset" class="secondary">Save only</button>
<button id="newAsset" class="secondary">New</button>
</div>
</div>
<div id="lineageNote" class="lineageNote"></div>
</section>
<section id="tab-library" class="tabPanel">
<div class="card stack">
<div class="libraryHead">
<div class="cardTitle">Library</div>
<button id="showHiddenAssets" class="tool" type="button">Hidden</button>
</div>
<p class="hint">Click an asset to select it and jump to it on the map. Copy Edit creates a new derivative.</p>
<div id="hiddenAssetPanel" class="hiddenAssetPanel" hidden>
<div class="cardTitle">Hidden assets</div>
<div id="hiddenAssetList" class="assetList compactAssetList"></div>
</div>
<div id="assetList" class="assetList"></div>
</div>
</section>
<section id="tab-data" class="tabPanel">
<div class="card stack">
<div class="cardTitle">Local save</div>
<p class="hint">Saved to this browser only. Export JSON if you want to move or share a local world.</p>
<div class="actionRow wrap">
<button id="exportData">Export full JSON</button>
<button id="exportCompact">Export compact</button>
<button id="exportSnapshot">Export snapshot</button>
<button id="exportAssetBundle">Export asset bundle</button>
<button id="importData">Import JSON</button>
<button id="resetAll" class="danger">Reset all</button>
</div>
<div id="syncStats" class="syncStats"></div>
<textarea id="dataBox" rows="10" placeholder="Exported JSON appears here."></textarea>
</div>
</section>
</div>
</section>
<div id="selectionBubble" class="selectionBubble" hidden>
<div id="bubbleName" class="bubbleName">Name</div>
<div id="bubbleAuthor" class="bubbleAuthor">by Author</div>
<div id="bubbleRemixFrom" class="bubbleRemixFrom" hidden></div>
<div id="bubbleRemixCount" class="bubbleRemixCount">Remixed: 0</div>
<div class="bubbleVotes">
<button id="voteUp" type="button"></button>
<span id="voteScore">0</span>
<button id="voteDown" type="button"></button>
</div>
<div class="bubbleActions">
<button id="bubbleRemix" type="button">Remix</button>
<button id="bubbleHide" type="button" hidden>Hide</button>
</div>
</div>
<div id="toast" class="toast" hidden></div>
</div>
<script src="./js/editor-actions.js"></script>
<script src="./js/phase2-sync.js"></script>
<script src="./app.js"></script>
</body>
</html>

View file

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

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

@ -0,0 +1,402 @@
(function () {
'use strict';
const root = window.PixelIslandModules ||= {};
const FORMAT = 'pixel-island-phase2-compact-v1';
const SNAPSHOT_FORMAT = 'pixel-island-phase2-snapshot-v1';
const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v1';
const EVENT_LOG_LIMIT = 300;
const DB_NAME = 'pixel-island-phase2-cache';
const DB_VERSION = 1;
const ASSET_STORE = 'assets';
const SNAPSHOT_STORE = 'snapshots';
function isCompactState(value) {
return Boolean(value && value.format === FORMAT && Array.isArray(value.assets));
}
function isAssetBundle(value) {
return Boolean(value && value.format === ASSET_BUNDLE_FORMAT && Array.isArray(value.assets));
}
function rleEncode(input) {
const text = String(input || '');
if (!text) return '';
let out = '';
let last = text[0];
let count = 1;
for (let i = 1; i < text.length; i++) {
const ch = text[i];
if (ch === last) count++;
else {
out += `${count}:${last}`;
last = ch;
count = 1;
}
}
out += `${count}:${last}`;
return out;
}
function rleDecode(input) {
const text = String(input || '');
if (!text) return '';
let out = '';
let i = 0;
while (i < text.length) {
let digits = '';
while (i < text.length && text[i] >= '0' && text[i] <= '9') digits += text[i++];
if (text[i] !== ':') break;
i++;
const ch = text[i++] || '';
const count = Math.max(0, Number(digits) || 0);
out += ch.repeat(count);
}
return out;
}
function normalizeEncodedPlane(value, size, emptyChar = '.') {
const total = Math.max(1, Number(size) || 1) ** 2;
const source = typeof value === 'string' ? value : Array.isArray(value) ? value.map((v) => v || emptyChar).join('') : '';
return (source + emptyChar.repeat(total)).slice(0, total);
}
function cropPlane(encoded, size, emptyChar = '.') {
const text = normalizeEncodedPlane(encoded, size, emptyChar);
let minX = size;
let minY = size;
let maxX = -1;
let maxY = -1;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (text[y * size + x] !== emptyChar) {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
}
}
if (maxX < 0) return { b: null, e: 'raw', v: '' };
const w = maxX - minX + 1;
const h = maxY - minY + 1;
let cropped = '';
for (let y = minY; y <= maxY; y++) {
cropped += text.slice(y * size + minX, y * size + minX + w);
}
const rle = rleEncode(cropped);
return rle.length < cropped.length ? { b: [minX, minY, w, h], e: 'rle', v: rle } : { b: [minX, minY, w, h], e: 'raw', v: cropped };
}
function expandPlane(packed, size, emptyChar = '.') {
const total = Math.max(1, Number(size) || 1) ** 2;
const out = Array(total).fill(emptyChar);
if (!packed || !packed.b) return out.join('');
const [x0, y0, w, h] = packed.b.map((v) => Math.max(0, Number(v) || 0));
const value = packed.e === 'rle' ? rleDecode(packed.v) : String(packed.v || '');
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const src = y * w + x;
const dx = x0 + x;
const dy = y0 + y;
if (dx >= 0 && dy >= 0 && dx < size && dy < size && src < value.length) {
out[dy * size + dx] = value[src] || emptyChar;
}
}
}
return out.join('');
}
function packAsset(asset) {
const size = Math.max(1, Number(asset.size) || 16);
const category = asset.category === 'dynamic' ? 'dynamic' : 'static';
const right = normalizeEncodedPlane(asset.faces?.right || asset.pixels || '', size, '.');
const depth = asset.meta?.depthPixels ? normalizeEncodedPlane(asset.meta.depthPixels, size, '.') : '';
const lights = Array.isArray(asset.meta?.lightPixels)
? asset.meta.lightPixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || asset.meta?.lightColor || '']).filter((p) => p[2])
: [];
const meta = {};
if (depth && /[1\-]/.test(depth)) meta.d = cropPlane(depth, size, '.');
if (lights.length) meta.l = lights;
if (asset.meta?.lightColor) meta.lc = asset.meta.lightColor;
if (asset.meta?.door) meta.dr = [Number(asset.meta.door.x) || 0, Number(asset.meta.door.y) || 0];
return {
id: asset.id,
h: asset.contentHash || asset.hash || null,
n: asset.name || 'Untitled',
c: category,
t: asset.subtype || (category === 'dynamic' ? 'human' : 'other'),
s: size,
p: cropPlane(right, size, '.'),
f: category === 'dynamic' ? { l: 'mirror' } : null,
pa: asset.parentAssetId || null,
oa: asset.originalAssetId || null,
ca: asset.createdAt || Date.now(),
ua: asset.updatedAt || asset.createdAt || Date.now(),
au: asset.author || 'Local Artist',
m: Object.keys(meta).length ? meta : null
};
}
function unpackAsset(packed) {
if (!packed || !packed.id) return null;
const size = Math.max(1, Number(packed.s || packed.size) || 16);
const category = packed.c === 'dynamic' || packed.category === 'dynamic' ? 'dynamic' : 'static';
const pixels = expandPlane(packed.p, size, '.');
const metaPacked = packed.m || {};
const lightPixels = Array.isArray(metaPacked.l)
? metaPacked.l.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || metaPacked.lc || 'a' }))
: [];
const meta = {
hasLight: lightPixels.length > 0,
lightPixels,
lightColor: lightPixels.length > 0 ? (metaPacked.lc || lightPixels[0]?.c || null) : null,
depthPixels: metaPacked.d ? expandPlane(metaPacked.d, size, '.') : null,
door: Array.isArray(metaPacked.dr) ? { x: Number(metaPacked.dr[0]) || 0, y: Number(metaPacked.dr[1]) || 0 } : null
};
return {
id: packed.id,
name: packed.n || 'Untitled',
category,
subtype: packed.t || (category === 'dynamic' ? 'human' : 'other'),
size,
pixels,
faces: category === 'dynamic' ? { right: pixels, left: 'mirror' } : null,
parentAssetId: packed.pa || null,
originalAssetId: packed.oa || null,
createdAt: packed.ca || Date.now(),
updatedAt: packed.ua || packed.ca || Date.now(),
author: packed.au || 'Local Artist',
meta,
contentHash: packed.h || null
};
}
function packPlacement(item) {
return [item.id, item.assetId, Number(item.x) || 0, Number(item.y) || 0, item.placedAt || Date.now(), Number(item.version) || 1];
}
function unpackPlacement(row) {
if (!Array.isArray(row)) return row;
return { id: row[0], assetId: row[1], x: row[2], y: row[3], placedAt: row[4], version: row[5] || 1 };
}
function packDynamic(item) {
return [item.id, item.assetId, Number(item.homeX) || 0, Number(item.homeY) || 0, item.createdAt || Date.now(), Number(item.version) || 1];
}
function unpackDynamic(row) {
if (!Array.isArray(row)) return row;
return { id: row[0], assetId: row[1], homeX: row[2], homeY: row[3], createdAt: row[4], version: row[5] || 1 };
}
function compactState(state) {
return {
schema: 3,
format: FORMAT,
authorName: state.authorName || 'Local Artist',
assets: Array.isArray(state.assets) ? state.assets.map(packAsset) : [],
placed: Array.isArray(state.placed) ? state.placed.map(packPlacement) : [],
dynamicSummons: Array.isArray(state.dynamicSummons) ? state.dynamicSummons.map(packDynamic) : [],
objectVotes: state.objectVotes || {},
assetVotes: state.assetVotes || {},
hiddenAssets: state.hiddenAssets || {},
hiddenObjects: state.hiddenObjects || {},
eventLog: Array.isArray(state.eventLog) ? state.eventLog.slice(-EVENT_LOG_LIMIT) : [],
sync: state.sync || { lastEventId: null }
};
}
function expandState(input) {
if (!isCompactState(input)) return input;
return {
schema: 3,
authorName: input.authorName || 'Local Artist',
assets: input.assets.map(unpackAsset).filter(Boolean),
placed: (input.placed || []).map(unpackPlacement),
dynamicSummons: (input.dynamicSummons || []).map(unpackDynamic),
objectVotes: input.objectVotes || {},
assetVotes: input.assetVotes || {},
hiddenAssets: input.hiddenAssets || {},
hiddenObjects: input.hiddenObjects || {},
eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-EVENT_LOG_LIMIT) : [],
sync: input.sync || { lastEventId: null }
};
}
function compactSizeReport(state) {
const full = JSON.stringify({ ...state, schema: 3 });
const compact = JSON.stringify(compactState(state));
return {
fullBytes: full.length,
compactBytes: compact.length,
savedBytes: Math.max(0, full.length - compact.length),
savedPercent: full.length ? Math.round((1 - compact.length / full.length) * 1000) / 10 : 0,
assets: state.assets?.length || 0,
objects: (state.placed?.length || 0) + (state.dynamicSummons?.length || 0)
};
}
function assetManifest(state) {
return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, s: asset.size, c: asset.category, t: asset.subtype }));
}
function makeSnapshot(state, worldId = 'local-main') {
return {
schema: 3,
format: SNAPSHOT_FORMAT,
worldId,
createdAt: Date.now(),
manifest: assetManifest(state),
placed: (state.placed || []).map(packPlacement),
dynamicSummons: (state.dynamicSummons || []).map(packDynamic),
hiddenObjects: state.hiddenObjects || {}
};
}
function makeAssetBundle(state, assetIds) {
const wanted = new Set(assetIds || []);
const assets = (state.assets || []).filter((asset) => !wanted.size || wanted.has(asset.id)).map(packAsset);
return { schema: 3, format: ASSET_BUNDLE_FORMAT, createdAt: Date.now(), assets };
}
function unpackAssetBundle(bundle) {
if (!isAssetBundle(bundle)) return [];
return bundle.assets.map(unpackAsset).filter(Boolean);
}
function findMissingAssetIds(snapshot, knownAssetIds) {
const known = new Set(knownAssetIds || []);
return (snapshot?.manifest || []).map((asset) => asset.id).filter((id) => id && !known.has(id));
}
function makeEvent(type, payload = {}) {
return { id: `ev_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, type, at: Date.now(), ...payload };
}
function createAssetUpsertEvent(asset) {
return makeEvent('asset.upsert', { asset: packAsset(asset) });
}
function createObjectUpsertEvent(kind, object) {
return makeEvent('object.upsert', { kind, object: kind === 'dynamic' ? packDynamic(object) : packPlacement(object) });
}
function createObjectDeleteEvent(kind, objectId) {
return makeEvent('object.delete', { kind, objectId });
}
function applyEvent(state, event) {
if (!state || !event) return state;
if (event.type === 'asset.upsert' && event.asset) {
const asset = unpackAsset(event.asset);
if (!asset) return state;
const index = (state.assets || []).findIndex((a) => a.id === asset.id);
if (index >= 0) state.assets[index] = asset;
else (state.assets ||= []).unshift(asset);
}
if (event.type === 'object.upsert') {
if (event.kind === 'dynamic') {
const object = unpackDynamic(event.object);
const list = state.dynamicSummons ||= [];
const index = list.findIndex((item) => item.id === object.id);
if (index >= 0) list[index] = object;
else list.push(object);
} else {
const object = unpackPlacement(event.object);
const list = state.placed ||= [];
const index = list.findIndex((item) => item.id === object.id);
if (index >= 0) list[index] = object;
else list.push(object);
}
}
if (event.type === 'object.delete') {
const key = event.kind === 'dynamic' ? 'dynamicSummons' : 'placed';
state[key] = (state[key] || []).filter((item) => item.id !== event.objectId);
}
return state;
}
function openDb() {
if (!('indexedDB' in window)) return Promise.reject(new Error('IndexedDB is not available.'));
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(ASSET_STORE)) db.createObjectStore(ASSET_STORE, { keyPath: 'id' });
if (!db.objectStoreNames.contains(SNAPSHOT_STORE)) db.createObjectStore(SNAPSHOT_STORE, { keyPath: 'worldId' });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function cacheAssets(assets) {
if (!Array.isArray(assets) || !assets.length) return;
const db = await openDb();
await new Promise((resolve, reject) => {
const tx = db.transaction(ASSET_STORE, 'readwrite');
for (const asset of assets) tx.objectStore(ASSET_STORE).put(packAsset(asset));
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
}
async function readCachedAssets(assetIds) {
const ids = Array.from(assetIds || []);
if (!ids.length) return [];
const db = await openDb();
const rows = await Promise.all(ids.map((id) => new Promise((resolve) => {
const request = db.transaction(ASSET_STORE, 'readonly').objectStore(ASSET_STORE).get(id);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => resolve(null);
})));
db.close();
return rows.filter(Boolean).map(unpackAsset).filter(Boolean);
}
async function cacheSnapshot(snapshot) {
if (!snapshot?.worldId) return;
const db = await openDb();
await new Promise((resolve, reject) => {
const tx = db.transaction(SNAPSHOT_STORE, 'readwrite');
tx.objectStore(SNAPSHOT_STORE).put(snapshot);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
}
root.Phase2Sync = {
FORMAT,
SNAPSHOT_FORMAT,
ASSET_BUNDLE_FORMAT,
EVENT_LOG_LIMIT,
isCompactState,
isAssetBundle,
rleEncode,
rleDecode,
cropPlane,
expandPlane,
packAsset,
unpackAsset,
compactState,
expandState,
compactSizeReport,
assetManifest,
makeSnapshot,
makeAssetBundle,
unpackAssetBundle,
findMissingAssetIds,
makeEvent,
createAssetUpsertEvent,
createObjectUpsertEvent,
createObjectDeleteEvent,
applyEvent,
cacheAssets,
readCachedAssets,
cacheSnapshot
};
})();

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

@ -0,0 +1,402 @@
(function () {
'use strict';
const root = window.PixelIslandModules ||= {};
const FORMAT = 'pixel-island-phase2-compact-v1';
const SNAPSHOT_FORMAT = 'pixel-island-phase2-snapshot-v1';
const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v1';
const EVENT_LOG_LIMIT = 300;
const DB_NAME = 'pixel-island-phase2-cache';
const DB_VERSION = 1;
const ASSET_STORE = 'assets';
const SNAPSHOT_STORE = 'snapshots';
function isCompactState(value) {
return Boolean(value && value.format === FORMAT && Array.isArray(value.assets));
}
function isAssetBundle(value) {
return Boolean(value && value.format === ASSET_BUNDLE_FORMAT && Array.isArray(value.assets));
}
function rleEncode(input) {
const text = String(input || '');
if (!text) return '';
let out = '';
let last = text[0];
let count = 1;
for (let i = 1; i < text.length; i++) {
const ch = text[i];
if (ch === last) count++;
else {
out += `${count}:${last}`;
last = ch;
count = 1;
}
}
out += `${count}:${last}`;
return out;
}
function rleDecode(input) {
const text = String(input || '');
if (!text) return '';
let out = '';
let i = 0;
while (i < text.length) {
let digits = '';
while (i < text.length && text[i] >= '0' && text[i] <= '9') digits += text[i++];
if (text[i] !== ':') break;
i++;
const ch = text[i++] || '';
const count = Math.max(0, Number(digits) || 0);
out += ch.repeat(count);
}
return out;
}
function normalizeEncodedPlane(value, size, emptyChar = '.') {
const total = Math.max(1, Number(size) || 1) ** 2;
const source = typeof value === 'string' ? value : Array.isArray(value) ? value.map((v) => v || emptyChar).join('') : '';
return (source + emptyChar.repeat(total)).slice(0, total);
}
function cropPlane(encoded, size, emptyChar = '.') {
const text = normalizeEncodedPlane(encoded, size, emptyChar);
let minX = size;
let minY = size;
let maxX = -1;
let maxY = -1;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (text[y * size + x] !== emptyChar) {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
}
}
if (maxX < 0) return { b: null, e: 'raw', v: '' };
const w = maxX - minX + 1;
const h = maxY - minY + 1;
let cropped = '';
for (let y = minY; y <= maxY; y++) {
cropped += text.slice(y * size + minX, y * size + minX + w);
}
const rle = rleEncode(cropped);
return rle.length < cropped.length ? { b: [minX, minY, w, h], e: 'rle', v: rle } : { b: [minX, minY, w, h], e: 'raw', v: cropped };
}
function expandPlane(packed, size, emptyChar = '.') {
const total = Math.max(1, Number(size) || 1) ** 2;
const out = Array(total).fill(emptyChar);
if (!packed || !packed.b) return out.join('');
const [x0, y0, w, h] = packed.b.map((v) => Math.max(0, Number(v) || 0));
const value = packed.e === 'rle' ? rleDecode(packed.v) : String(packed.v || '');
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const src = y * w + x;
const dx = x0 + x;
const dy = y0 + y;
if (dx >= 0 && dy >= 0 && dx < size && dy < size && src < value.length) {
out[dy * size + dx] = value[src] || emptyChar;
}
}
}
return out.join('');
}
function packAsset(asset) {
const size = Math.max(1, Number(asset.size) || 16);
const category = asset.category === 'dynamic' ? 'dynamic' : 'static';
const right = normalizeEncodedPlane(asset.faces?.right || asset.pixels || '', size, '.');
const depth = asset.meta?.depthPixels ? normalizeEncodedPlane(asset.meta.depthPixels, size, '.') : '';
const lights = Array.isArray(asset.meta?.lightPixels)
? asset.meta.lightPixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || asset.meta?.lightColor || '']).filter((p) => p[2])
: [];
const meta = {};
if (depth && /1/.test(depth)) meta.d = cropPlane(depth, size, '.');
if (lights.length) meta.l = lights;
if (asset.meta?.lightColor) meta.lc = asset.meta.lightColor;
if (asset.meta?.door) meta.dr = [Number(asset.meta.door.x) || 0, Number(asset.meta.door.y) || 0];
return {
id: asset.id,
h: asset.contentHash || asset.hash || null,
n: asset.name || 'Untitled',
c: category,
t: asset.subtype || (category === 'dynamic' ? 'human' : 'other'),
s: size,
p: cropPlane(right, size, '.'),
f: category === 'dynamic' ? { l: 'mirror' } : null,
pa: asset.parentAssetId || null,
oa: asset.originalAssetId || null,
ca: asset.createdAt || Date.now(),
ua: asset.updatedAt || asset.createdAt || Date.now(),
au: asset.author || 'Local Artist',
m: Object.keys(meta).length ? meta : null
};
}
function unpackAsset(packed) {
if (!packed || !packed.id) return null;
const size = Math.max(1, Number(packed.s || packed.size) || 16);
const category = packed.c === 'dynamic' || packed.category === 'dynamic' ? 'dynamic' : 'static';
const pixels = expandPlane(packed.p, size, '.');
const metaPacked = packed.m || {};
const lightPixels = Array.isArray(metaPacked.l)
? metaPacked.l.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || metaPacked.lc || 'a' }))
: [];
const meta = {
hasLight: lightPixels.length > 0,
lightPixels,
lightColor: lightPixels.length > 0 ? (metaPacked.lc || lightPixels[0]?.c || null) : null,
depthPixels: metaPacked.d ? expandPlane(metaPacked.d, size, '.') : null,
door: Array.isArray(metaPacked.dr) ? { x: Number(metaPacked.dr[0]) || 0, y: Number(metaPacked.dr[1]) || 0 } : null
};
return {
id: packed.id,
name: packed.n || 'Untitled',
category,
subtype: packed.t || (category === 'dynamic' ? 'human' : 'other'),
size,
pixels,
faces: category === 'dynamic' ? { right: pixels, left: 'mirror' } : null,
parentAssetId: packed.pa || null,
originalAssetId: packed.oa || null,
createdAt: packed.ca || Date.now(),
updatedAt: packed.ua || packed.ca || Date.now(),
author: packed.au || 'Local Artist',
meta,
contentHash: packed.h || null
};
}
function packPlacement(item) {
return [item.id, item.assetId, Number(item.x) || 0, Number(item.y) || 0, item.placedAt || Date.now(), Number(item.version) || 1];
}
function unpackPlacement(row) {
if (!Array.isArray(row)) return row;
return { id: row[0], assetId: row[1], x: row[2], y: row[3], placedAt: row[4], version: row[5] || 1 };
}
function packDynamic(item) {
return [item.id, item.assetId, Number(item.homeX) || 0, Number(item.homeY) || 0, item.createdAt || Date.now(), Number(item.version) || 1];
}
function unpackDynamic(row) {
if (!Array.isArray(row)) return row;
return { id: row[0], assetId: row[1], homeX: row[2], homeY: row[3], createdAt: row[4], version: row[5] || 1 };
}
function compactState(state) {
return {
schema: 3,
format: FORMAT,
authorName: state.authorName || 'Local Artist',
assets: Array.isArray(state.assets) ? state.assets.map(packAsset) : [],
placed: Array.isArray(state.placed) ? state.placed.map(packPlacement) : [],
dynamicSummons: Array.isArray(state.dynamicSummons) ? state.dynamicSummons.map(packDynamic) : [],
objectVotes: state.objectVotes || {},
assetVotes: state.assetVotes || {},
hiddenAssets: state.hiddenAssets || {},
hiddenObjects: state.hiddenObjects || {},
eventLog: Array.isArray(state.eventLog) ? state.eventLog.slice(-EVENT_LOG_LIMIT) : [],
sync: state.sync || { lastEventId: null }
};
}
function expandState(input) {
if (!isCompactState(input)) return input;
return {
schema: 3,
authorName: input.authorName || 'Local Artist',
assets: input.assets.map(unpackAsset).filter(Boolean),
placed: (input.placed || []).map(unpackPlacement),
dynamicSummons: (input.dynamicSummons || []).map(unpackDynamic),
objectVotes: input.objectVotes || {},
assetVotes: input.assetVotes || {},
hiddenAssets: input.hiddenAssets || {},
hiddenObjects: input.hiddenObjects || {},
eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-EVENT_LOG_LIMIT) : [],
sync: input.sync || { lastEventId: null }
};
}
function compactSizeReport(state) {
const full = JSON.stringify({ ...state, schema: 3 });
const compact = JSON.stringify(compactState(state));
return {
fullBytes: full.length,
compactBytes: compact.length,
savedBytes: Math.max(0, full.length - compact.length),
savedPercent: full.length ? Math.round((1 - compact.length / full.length) * 1000) / 10 : 0,
assets: state.assets?.length || 0,
objects: (state.placed?.length || 0) + (state.dynamicSummons?.length || 0)
};
}
function assetManifest(state) {
return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, s: asset.size, c: asset.category, t: asset.subtype }));
}
function makeSnapshot(state, worldId = 'local-main') {
return {
schema: 3,
format: SNAPSHOT_FORMAT,
worldId,
createdAt: Date.now(),
manifest: assetManifest(state),
placed: (state.placed || []).map(packPlacement),
dynamicSummons: (state.dynamicSummons || []).map(packDynamic),
hiddenObjects: state.hiddenObjects || {}
};
}
function makeAssetBundle(state, assetIds) {
const wanted = new Set(assetIds || []);
const assets = (state.assets || []).filter((asset) => !wanted.size || wanted.has(asset.id)).map(packAsset);
return { schema: 3, format: ASSET_BUNDLE_FORMAT, createdAt: Date.now(), assets };
}
function unpackAssetBundle(bundle) {
if (!isAssetBundle(bundle)) return [];
return bundle.assets.map(unpackAsset).filter(Boolean);
}
function findMissingAssetIds(snapshot, knownAssetIds) {
const known = new Set(knownAssetIds || []);
return (snapshot?.manifest || []).map((asset) => asset.id).filter((id) => id && !known.has(id));
}
function makeEvent(type, payload = {}) {
return { id: `ev_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, type, at: Date.now(), ...payload };
}
function createAssetUpsertEvent(asset) {
return makeEvent('asset.upsert', { asset: packAsset(asset) });
}
function createObjectUpsertEvent(kind, object) {
return makeEvent('object.upsert', { kind, object: kind === 'dynamic' ? packDynamic(object) : packPlacement(object) });
}
function createObjectDeleteEvent(kind, objectId) {
return makeEvent('object.delete', { kind, objectId });
}
function applyEvent(state, event) {
if (!state || !event) return state;
if (event.type === 'asset.upsert' && event.asset) {
const asset = unpackAsset(event.asset);
if (!asset) return state;
const index = (state.assets || []).findIndex((a) => a.id === asset.id);
if (index >= 0) state.assets[index] = asset;
else (state.assets ||= []).unshift(asset);
}
if (event.type === 'object.upsert') {
if (event.kind === 'dynamic') {
const object = unpackDynamic(event.object);
const list = state.dynamicSummons ||= [];
const index = list.findIndex((item) => item.id === object.id);
if (index >= 0) list[index] = object;
else list.push(object);
} else {
const object = unpackPlacement(event.object);
const list = state.placed ||= [];
const index = list.findIndex((item) => item.id === object.id);
if (index >= 0) list[index] = object;
else list.push(object);
}
}
if (event.type === 'object.delete') {
const key = event.kind === 'dynamic' ? 'dynamicSummons' : 'placed';
state[key] = (state[key] || []).filter((item) => item.id !== event.objectId);
}
return state;
}
function openDb() {
if (!('indexedDB' in window)) return Promise.reject(new Error('IndexedDB is not available.'));
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(ASSET_STORE)) db.createObjectStore(ASSET_STORE, { keyPath: 'id' });
if (!db.objectStoreNames.contains(SNAPSHOT_STORE)) db.createObjectStore(SNAPSHOT_STORE, { keyPath: 'worldId' });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function cacheAssets(assets) {
if (!Array.isArray(assets) || !assets.length) return;
const db = await openDb();
await new Promise((resolve, reject) => {
const tx = db.transaction(ASSET_STORE, 'readwrite');
for (const asset of assets) tx.objectStore(ASSET_STORE).put(packAsset(asset));
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
}
async function readCachedAssets(assetIds) {
const ids = Array.from(assetIds || []);
if (!ids.length) return [];
const db = await openDb();
const rows = await Promise.all(ids.map((id) => new Promise((resolve) => {
const request = db.transaction(ASSET_STORE, 'readonly').objectStore(ASSET_STORE).get(id);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => resolve(null);
})));
db.close();
return rows.filter(Boolean).map(unpackAsset).filter(Boolean);
}
async function cacheSnapshot(snapshot) {
if (!snapshot?.worldId) return;
const db = await openDb();
await new Promise((resolve, reject) => {
const tx = db.transaction(SNAPSHOT_STORE, 'readwrite');
tx.objectStore(SNAPSHOT_STORE).put(snapshot);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
}
root.Phase2Sync = {
FORMAT,
SNAPSHOT_FORMAT,
ASSET_BUNDLE_FORMAT,
EVENT_LOG_LIMIT,
isCompactState,
isAssetBundle,
rleEncode,
rleDecode,
cropPlane,
expandPlane,
packAsset,
unpackAsset,
compactState,
expandState,
compactSizeReport,
assetManifest,
makeSnapshot,
makeAssetBundle,
unpackAssetBundle,
findMissingAssetIds,
makeEvent,
createAssetUpsertEvent,
createObjectUpsertEvent,
createObjectDeleteEvent,
applyEvent,
cacheAssets,
readCachedAssets,
cacheSnapshot
};
})();

View file

@ -25,7 +25,7 @@ body {
margin: 0;
color: var(--ink);
background: #92d8ff;
font-family: "Courier New", "Monaco", "Lucida Console", ui-monospace, monospace;
font-family: "3x5 MT Pixel Font", "Press Start 2P", "PixelMplus10", "DotGothic16", "Courier New", "Monaco", "Lucida Console", ui-monospace, monospace;
overflow: hidden;
}
@ -424,8 +424,13 @@ input[type="checkbox"] { width: auto; margin: 0 8px 0 0; }
}
.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;
@ -835,7 +840,8 @@ body, button, input, select, textarea {
-webkit-font-smoothing: none;
text-rendering: geometricPrecision;
}
.toolRow { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.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;
@ -879,5 +885,17 @@ body, button, input, select, textarea {
.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;
}

901
styles.css.bak Normal file
View file

@ -0,0 +1,901 @@
:root {
--ink: #243044;
--muted: #6f7b91;
--paper: #fff7e8;
--panel: rgba(255, 248, 232, .94);
--panel-solid: #fff8e9;
--panel-2: #ffeec9;
--line: #2d3c50;
--line-soft: rgba(45, 60, 80, .18);
--pink: #ff85b3;
--pink-dark: #f05f98;
--mint: #73d6a4;
--sky: #77c9ff;
--sun: #ffd66e;
--danger: #ff6b6b;
--shadow: 6px 6px 0 rgba(34, 45, 63, .14);
--hard-shadow: 4px 4px 0 rgba(34, 45, 63, .22);
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
color: var(--ink);
background: #92d8ff;
font-family: "Courier New", "Monaco", "Lucida Console", ui-monospace, monospace;
overflow: hidden;
}
button, input, select, textarea { font: inherit; }
button { -webkit-tap-highlight-color: transparent; }
.app {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
}
#worldCanvas {
display: block;
width: 100vw;
height: 100vh;
image-rendering: pixelated;
cursor: grab;
background: #8cd4ff;
}
#worldCanvas.dragging { cursor: grabbing; }
#worldCanvas.placeCursor { cursor: copy; }
#worldCanvas.eraseCursor { cursor: not-allowed; }
.hud {
position: absolute;
z-index: 5;
background: var(--panel);
border: 2px solid var(--line);
border-radius: 0;
box-shadow: var(--shadow);
backdrop-filter: blur(8px);
}
.topHud {
top: 14px;
left: 14px;
right: 14px;
min-height: 70px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
pointer-events: none;
}
.logo {
font-weight: 900;
letter-spacing: .04em;
font-size: 20px;
}
.logo span {
display: inline-block;
background: var(--pink);
border: 2px solid var(--line);
padding: 1px 6px;
margin-left: 6px;
font-size: 12px;
transform: rotate(-2deg);
}
.subline {
margin-top: 3px;
color: var(--muted);
font-size: 12px;
}
.authorCard {
width: 190px;
border: 2px solid var(--line);
background: #fffdf5;
padding: 6px 8px;
pointer-events: auto;
}
.authorCard span {
display: block;
font-size: 10px;
font-weight: 900;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--muted);
}
.authorCard input {
margin-top: 3px;
padding: 5px 6px;
border-width: 2px;
background: #fffaf0;
}
.clockCard {
width: 230px;
border: 2px solid var(--line);
background: #fffdf5;
padding: 8px 10px;
}
.phaseLabel {
font-weight: 900;
letter-spacing: .06em;
text-transform: uppercase;
font-size: 13px;
}
.phaseBar {
margin-top: 6px;
height: 10px;
background: #dfe8f0;
border: 2px solid var(--line);
overflow: hidden;
}
.phaseBar span {
display: block;
width: 0%;
height: 100%;
background: linear-gradient(90deg, #ffd66e, #77c9ff, #6862e8);
}
.clockHint {
margin-top: 4px;
color: var(--muted);
font-size: 11px;
}
.edgeAdd {
position: absolute;
z-index: 8;
left: 0;
top: 50%;
width: 56px;
height: 86px;
transform: translateY(-50%);
border: 3px solid var(--line);
border-left: 0;
border-radius: 0 18px 18px 0;
background: var(--pink);
color: white;
font-size: 42px;
font-weight: 900;
line-height: 1;
box-shadow: var(--hard-shadow);
cursor: pointer;
}
.edgeAdd:hover { background: var(--pink-dark); }
.edgeAdd:active { transform: translateY(calc(-50% + 2px)); box-shadow: 2px 2px 0 rgba(34,45,63,.22); }
.toolHud {
right: 14px;
top: 116px;
display: grid;
grid-template-columns: 1fr;
gap: 7px;
padding: 9px;
}
.iconButton,
button,
.ghostButton,
.tab,
.tool,
.segmented button {
border: 2px solid var(--line);
border-radius: 0;
background: #fffdf5;
color: var(--ink);
padding: 8px 11px;
font-weight: 850;
cursor: pointer;
box-shadow: 3px 3px 0 rgba(36, 48, 68, .16);
transition: transform .05s, box-shadow .05s, background .12s;
}
button:hover,
.ghostButton:hover,
.tab:hover,
.tool:hover,
.segmented button:hover { background: #fff1c8; }
button:active,
.ghostButton:active,
.tab:active,
.tool:active,
.segmented button:active {
transform: translate(2px, 2px);
box-shadow: 1px 1px 0 rgba(36, 48, 68, .16);
}
button.active,
.tab.active,
.tool.active,
.segmented button.active {
background: var(--sun);
box-shadow: inset 0 0 0 2px rgba(255,255,255,.4), 3px 3px 0 rgba(36,48,68,.18);
}
button.primary {
background: var(--mint);
color: #163a2a;
}
button.secondary { background: var(--panel-2); }
button.danger, .tool.danger, .iconButton.danger { background: #ffe4e4; color: #833232; }
button.danger.active, .iconButton.danger.active { background: var(--danger); color: #fffdf5; }
.divider {
height: 2px;
background: var(--line);
opacity: .22;
margin: 2px 0;
}
.infoHud {
right: 14px;
bottom: 14px;
width: min(360px, calc(100vw - 28px));
padding: 12px;
}
.selectedAsset {
font-weight: 900;
padding-bottom: 8px;
border-bottom: 2px dashed rgba(45,60,80,.25);
}
.tileInfo {
white-space: pre-wrap;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
margin-top: 8px;
}
.studioDrawer {
position: absolute;
z-index: 7;
top: 0;
left: 0;
width: min(500px, calc(100vw - 20px));
height: 100vh;
background: var(--panel-solid);
border-right: 3px solid var(--line);
box-shadow: 10px 0 0 rgba(36, 48, 68, .12);
transform: translateX(-104%);
transition: transform .18s ease-out;
display: grid;
grid-template-rows: auto auto 1fr;
}
.studioDrawer.open { transform: translateX(0); }
.drawerHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 10px 12px 8px;
border-bottom: 3px solid var(--line);
background: #ffe6f0;
}
.drawerHead h1 {
margin: 0;
font-size: 24px;
font-weight: 950;
}
.drawerHead p {
margin: 3px 0 0;
color: var(--muted);
font-size: 12px;
}
.ghostButton {
width: 36px;
height: 36px;
padding: 0;
font-size: 24px;
line-height: 1;
}
.tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
padding: 8px;
background: #fff2d0;
border-bottom: 3px solid var(--line);
}
.tab { padding: 8px 5px; font-size: 13px; }
.drawerBody {
overflow-y: auto;
padding: 8px;
background:
linear-gradient(45deg, rgba(255,255,255,.4) 25%, transparent 25%) 0 0/18px 18px,
var(--panel-solid);
}
.tabPanel { display: none; }
.tabPanel.active { display: block; }
.compactNameField { margin-bottom: 5px; }
.roleHintInline { margin: 0 0 10px; }
.card {
background: #fffdf5;
border: 2px solid var(--line);
box-shadow: 4px 4px 0 rgba(36,48,68,.12);
padding: 9px;
margin-bottom: 8px;
}
.stack > * + * { margin-top: 10px; }
.cardTitle {
font-weight: 950;
text-transform: uppercase;
letter-spacing: .06em;
font-size: 12px;
color: #3c4b60;
}
.field {
display: block;
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
input, select, textarea {
display: block;
width: 100%;
margin-top: 5px;
border: 2px solid var(--line);
border-radius: 0;
background: #fffaf0;
color: var(--ink);
padding: 9px 10px;
outline: none;
}
input:focus, select:focus, textarea:focus { background: #fff; box-shadow: 0 0 0 3px rgba(119,201,255,.35); }
textarea { resize: vertical; }
input[type="color"] { height: 39px; padding: 3px; }
input[type="checkbox"] { width: auto; margin: 0 8px 0 0; }
.twoCols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.segmented {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.segmented button { font-size: 12px; padding: 8px 5px; }
.hint {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
margin: 0;
}
.editorCard { padding-bottom: 10px; }
.editorTop {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 9px;
}
.sideSwitcher {
display: flex;
gap: 5px;
align-items: center;
}
.sideSwitcher button { padding: 6px 7px; font-size: 11px; }
.toolRow {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
align-items: center;
margin-bottom: 8px;
}
.editorToolRow { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.editorHistoryRow { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.editorMoveRow { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.editorFileRow { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.toolRow input { margin: 0; }
.tool { padding: 7px 5px; font-size: 11px; }
button:disabled, .tool:disabled { opacity: .45; cursor: not-allowed; transform: none; box-shadow: none; }
#paintCanvas {
display: block;
width: min(100%, 330px);
margin: 0 auto;
aspect-ratio: 1 / 1;
image-rendering: pixelated;
border: 3px solid var(--line);
background:
linear-gradient(45deg, #f4ebdc 25%, transparent 25%) 0 0/16px 16px,
linear-gradient(45deg, transparent 75%, #f4ebdc 75%) 0 0/16px 16px,
#fffaf0;
cursor: crosshair;
user-select: none;
touch-action: none;
}
.compactSettings .twoCols { align-items: center; }
.toggleLine {
color: var(--ink);
font-size: 13px;
font-weight: 850;
display: flex;
align-items: center;
}
.behaviorNote {
border: 2px dashed rgba(45,60,80,.22);
padding: 8px;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.actionRow {
display: flex;
gap: 9px;
margin: 12px 0;
}
.actionRow.wrap { flex-wrap: wrap; }
.actionRow button { flex: 1; }
.lineageNote { min-height: 18px; color: var(--muted); font-size: 12px; }
.assetList { display: grid; gap: 10px; }
.assetCard {
display: grid;
grid-template-columns: 68px 1fr;
gap: 10px;
border: 2px solid var(--line);
background: #fff9e8;
padding: 8px;
box-shadow: 3px 3px 0 rgba(36,48,68,.12);
}
.assetCard.selected { background: #e6ffef; }
.assetPreview {
width: 64px;
height: 64px;
image-rendering: pixelated;
background: #fff3d9;
border: 2px solid var(--line);
}
.assetMeta strong { display: block; font-size: 14px; }
.assetMeta span { display: block; margin-top: 3px; color: var(--muted); font-size: 12px; line-height: 1.3; }
.assetActions {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 8px;
}
.assetActions button { padding: 5px 7px; font-size: 11px; }
.modeGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.modeGrid button { padding: 8px 5px; font-size: 12px; }
.legend {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
font-size: 12px;
color: var(--muted);
}
.legend span { display: flex; align-items: center; gap: 6px; }
.legend i {
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid var(--line);
}
.legend .water { background: #67b5d9; }
.legend .sand { background: #ead493; }
.legend .grass { background: #8bce76; }
.legend .highland { background: #a7be80; }
#dataBox { min-height: 160px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; }
.toast {
position: absolute;
z-index: 20;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
background: #fffdf5;
border: 3px solid var(--line);
box-shadow: var(--hard-shadow);
padding: 10px 14px;
font-weight: 900;
}
[hidden] { display: none !important; }
@media (max-width: 760px) {
.topHud { align-items: flex-start; flex-direction: column; right: 70px; }
.clockCard, .authorCard { width: 100%; }
.toolHud { top: auto; bottom: 122px; right: 10px; }
.infoHud { left: 10px; right: 10px; width: auto; }
.studioDrawer { width: calc(100vw - 18px); }
.toolRow { grid-template-columns: 44px repeat(3, minmax(0, 1fr)); }
}
/* refinements */
.toolHud {
top: 142px;
right: 18px;
}
.edgeAdd {
top: calc(50% + 24px);
width: 62px;
height: 92px;
background: linear-gradient(180deg, #ff97c2, #ff75aa);
}
.studioDrawer {
width: min(520px, calc(100vw - 28px));
}
.drawerHead { background: #ffe1ef; }
.drawerBody {
background:
linear-gradient(90deg, rgba(255,255,255,.42) 0 1px, transparent 1px) 0 0/20px 20px,
linear-gradient(0deg, rgba(255,255,255,.42) 0 1px, transparent 1px) 0 0/20px 20px,
#fff8e9;
}
.card {
border-width: 3px;
box-shadow: 5px 5px 0 rgba(36,48,68,.13);
}
.heroEditor {
background: #fffdf7;
border-color: #26364c;
}
#paintCanvas {
margin-bottom: 10px;
max-height: min(42vh, 330px);
}
.toolRow {
grid-template-columns: repeat(5, minmax(0, 1fr));
margin-top: 0;
}
.paletteGrid {
display: grid;
grid-template-columns: repeat(13, 1fr);
gap: 3px;
margin: 6px 0 6px;
padding: 6px;
border: 2px solid rgba(36,48,68,.28);
background: #fff6df;
}
.paletteSwatch {
position: relative;
aspect-ratio: 1 / 1;
min-height: 18px;
border: 2px solid rgba(36,48,68,.38);
box-shadow: 2px 2px 0 rgba(36,48,68,.13);
cursor: pointer;
}
.paletteSwatch.active {
outline: 3px solid #ff7aad;
outline-offset: 1px;
border-color: #26364c;
}
.paletteSwatch::after {
content: attr(data-code);
position: absolute;
right: 2px;
bottom: 0;
color: rgba(20,26,38,.52);
font-size: 7px;
font-weight: 900;
text-shadow: 0 1px 0 rgba(255,255,255,.7);
}
.quickSetupBar { margin-bottom: 6px; }
.settingsCard .segmented { margin-top: 2px; }
.markerRow {
display: grid;
gap: 6px;
}
.markerChip {
border: 2px dashed rgba(36,48,68,.25);
background: #fff6df;
padding: 8px;
font-size: 12px;
color: var(--muted);
font-weight: 800;
}
.summonCard {
background: linear-gradient(180deg, #fffdf5, #fff2d7);
}
.finishActions {
display: grid;
grid-template-columns: 1.35fr .8fr .6fr;
}
.bigPrimary {
min-height: 48px;
font-size: 14px;
background: linear-gradient(180deg, #89e4ae, #65d596) !important;
}
.sideSwitcher button:first-child.active::after,
.sideSwitcher button:nth-child(2).active::after {
content: "";
display: inline-block;
width: 6px;
}
.spawnPop {
pointer-events: none;
}
.selectedAsset { font-weight: 900; padding-bottom: 8px; border-bottom: 2px dashed rgba(45,60,80,.25); }
@media (max-width: 720px) {
.toolHud { top: auto; bottom: 126px; right: 10px; }
.finishActions { grid-template-columns: 1fr; }
.paletteGrid { grid-template-columns: repeat(8, 1fr); }
}
.paletteGrid button { width: 100%; aspect-ratio: 1 / 1; padding: 0; min-height: 22px; }
.sideSwitcher button { white-space: nowrap; }
.heroEditor .hint { line-height: 1.35; }
.compactEditorTop { margin-bottom: 4px; min-height: 0; }
body, button, input, select, textarea { font-smooth: never; -webkit-font-smoothing: none; }
/* latest layout fixes */
.studioDrawer { width: min(600px, calc(100vw - 24px)); }
.paintLayout {
display: grid;
grid-template-columns: minmax(220px, 360px) 1fr;
align-items: start;
gap: 10px;
margin: 4px 0 8px;
}
#paintCanvas {
width: 100%;
max-width: 360px;
max-height: none;
margin: 0;
}
.paletteGrid {
margin: 0;
grid-template-columns: repeat(6, 1fr);
align-content: start;
gap: 4px;
max-height: 360px;
overflow: hidden;
}
.paletteSwatch, .paletteGrid button {
min-height: 20px;
}
.sideSwitcher { justify-content: flex-start; }
.authorCard span { white-space: nowrap; }
@media (max-width: 720px) {
.paintLayout { grid-template-columns: 1fr; }
.paletteGrid { grid-template-columns: repeat(10, 1fr); max-height: none; }
}
/* Selection bubble + outline controls */
.palettePanel { display: grid; gap: 8px; }
.selectionBubble {
position: absolute;
left: 0;
top: 0;
z-index: 9;
min-width: 126px;
max-width: 210px;
padding: 8px;
background: #fffdf5;
border: 3px solid var(--line);
box-shadow: 4px 4px 0 rgba(36,48,68,.20);
pointer-events: auto;
text-align: center;
}
.selectionBubble::after {
content: "";
position: absolute;
left: 50%;
bottom: -10px;
width: 14px;
height: 14px;
background: #fffdf5;
border-right: 3px solid var(--line);
border-bottom: 3px solid var(--line);
transform: translateX(-50%) rotate(45deg);
}
.bubbleName {
font-size: 12px;
font-weight: 950;
line-height: 1.2;
word-break: break-word;
}
.bubbleAuthor {
margin-top: 2px;
font-size: 10px;
color: var(--muted);
font-weight: 800;
}
.bubbleVotes {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 6px;
align-items: center;
margin-top: 6px;
}
.bubbleVotes button {
padding: 3px 7px;
min-width: 30px;
font-size: 11px;
}
#voteScore {
font-weight: 950;
font-size: 12px;
min-width: 20px;
}
.bubbleRemixFrom, .bubbleRemixCount {
margin-top: 3px;
color: var(--muted);
font-size: 10px;
font-weight: 800;
}
.bubbleActions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 5px;
margin-top: 6px;
}
.bubbleActions button {
padding: 4px 6px;
font-size: 10px;
}
.bubbleVotes button.mutedVote, .assetActions button.mutedAction {
opacity: .45;
}
.bubbleVotes button.activeVote {
background: var(--sun);
}
/* Pixel-ish UI refinements */
body, button, input, select, textarea {
font-family: "Courier New", "Lucida Console", Monaco, monospace;
letter-spacing: .02em;
}
button, .tab, .tool, .iconButton, .cardTitle, .logo {
text-transform: uppercase;
font-weight: 900;
}
.libraryHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.hiddenAssetPanel {
border: 2px dashed rgba(36,48,68,.35);
background: #fff3d6;
padding: 8px;
}
.compactAssetList .assetCard { opacity: .82; }
.librarySectionTitle {
margin: 12px 0 6px;
padding: 5px 7px;
background: #ffe6f0;
border: 2px solid var(--line);
box-shadow: 3px 3px 0 rgba(36,48,68,.12);
font-size: 12px;
font-weight: 900;
}
.advancedRow {
display: grid;
grid-template-columns: max-content 1fr;
gap: 8px;
align-items: center;
margin-top: 8px;
}
.advancedOnly.active { background: #bde9ff; }
.depthLegend {
font-size: 10px;
color: var(--muted);
}
/* Simplified current UI */
body, button, input, select, textarea {
font-family: "MS Gothic", "Osaka-Mono", "DotumChe", "Courier New", ui-monospace, monospace;
letter-spacing: .02em;
-webkit-font-smoothing: none;
text-rendering: geometricPrecision;
}
.toolRow { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.editorHistoryRow { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.advancedRow {
grid-template-columns: max-content max-content max-content 1fr;
gap: 6px;
}
.librarySectionTitle {
display: block;
width: 100%;
text-align: left;
margin: 10px 0 6px;
cursor: pointer;
}
.librarySectionTitle::after {
content: " click: genre";
float: right;
color: var(--muted);
font-size: 10px;
}
.assetSectionGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
max-height: 360px;
overflow-y: auto;
padding-right: 4px;
}
.libraryEmptyNote {
color: var(--muted);
font-size: 12px;
border: 2px dashed rgba(36,48,68,.2);
padding: 8px;
background: #fff9e8;
}
.assetSectionGrid .assetCard {
grid-template-columns: 54px 1fr;
gap: 8px;
}
.assetSectionGrid .assetPreview {
width: 52px;
height: 52px;
}
.assetSectionGrid .assetActions button { padding: 4px 5px; font-size: 10px; }
@media (max-width: 720px) {
.assetSectionGrid { grid-template-columns: 1fr; max-height: none; }
.toolRow { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.editorHistoryRow { grid-template-columns: 1fr 1fr; }
.advancedRow { grid-template-columns: 1fr 1fr 1fr; }
}
.syncStats {
padding: 8px 10px;
border-radius: 12px;
background: rgba(20, 25, 32, 0.06);
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}