updateeeed
This commit is contained in:
parent
89a32dc28e
commit
5b5848f1dc
11 changed files with 7269 additions and 208 deletions
70
README.md
70
README.md
|
|
@ -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
58
README.md.bak
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Pixel Island Summoner
|
||||
|
||||
Browser-only prototype for a shared pixel-art island.
|
||||
|
||||
## Run
|
||||
|
||||
Open `index.html` in a modern browser. No build step or third-party dependency is required.
|
||||
|
||||
## Current prototype scope
|
||||
|
||||
- Draw pixel assets in the in-browser editor.
|
||||
- Save assets to the local library.
|
||||
- Place static assets and summon dynamic assets onto the island.
|
||||
- Dynamic assets default to right-facing on spawn; left-facing is rendered by mirroring during movement only.
|
||||
- Local voting, hiding, remix/copy edit, and import/export tools are included.
|
||||
|
||||
## Phase 1 changes
|
||||
|
||||
- Asset data is normalized to schema 2+ compatible shape.
|
||||
- Dynamic left-facing pixels are not stored; `faces.left` is represented as `mirror`.
|
||||
- Empty depth/light metadata is omitted or normalized.
|
||||
- Editor tools include Draw, Erase, Fill, Pick, Undo, and Redo.
|
||||
|
||||
## Phase 2 changes
|
||||
|
||||
- Local save uses a compact Phase 2 envelope in `localStorage`.
|
||||
- Pixel planes are bbox-cropped and RLE-compressed when this is smaller than raw cropped data.
|
||||
- Compact asset bundles can be exported separately from world snapshots.
|
||||
- World snapshots contain placements, dynamic summons, and an asset manifest rather than full asset blobs.
|
||||
- Local object/asset mutations append small sync events to `eventLog`.
|
||||
- IndexedDB asset/snapshot cache helpers are included for future server-backed missing-asset fetches.
|
||||
|
||||
## Phase 3 changes
|
||||
|
||||
- Terrain rendering is chunk-cached instead of using one large terrain canvas.
|
||||
- Only terrain chunks intersecting the current viewport are drawn.
|
||||
- Static and dynamic objects outside the viewport margin are culled before sprite generation/sorting.
|
||||
- Dynamic runtime updates are stepped at 15 Hz and capped per frame to avoid spiral-of-death on slow devices.
|
||||
- Rendering pauses while the browser tab is hidden and resumes on visibility change.
|
||||
- Tile/object lookups use a small spatial index for inspect, erase, and nearby-target searches.
|
||||
- Data tab reports visible terrain chunks for quick performance inspection.
|
||||
|
||||
## Phase 4 changes
|
||||
|
||||
- Editor schema is now 5.
|
||||
- Added Line and Rect tools. Rect supports filled rectangles with Shift; right-click erases line/rect cells.
|
||||
- Added Select tool. Drag to select a rectangular area, then drag the selection or use arrow keys/buttons to move it.
|
||||
- Nudge buttons move the active selection; without a selection they shift the whole drawing.
|
||||
- Added horizontal/vertical flip, palette-colored outline generation, and clear selection.
|
||||
- Added editor PNG export/import. PNG import quantizes the image to the current island palette and current canvas size.
|
||||
- Added keyboard shortcuts: B/E/F/I/L/R/S for tools, Ctrl/Cmd+Z/Y for history, arrows for moving selected pixels, Escape to clear selection.
|
||||
|
||||
## Export formats
|
||||
|
||||
- **Export full JSON**: human-readable full local state.
|
||||
- **Export compact**: compact local state suitable for saving/transferring.
|
||||
- **Export snapshot**: placement state plus asset manifest; requires asset bundles or cache to render fully.
|
||||
- **Export asset bundle**: compact asset blob list for missing-asset transfer.
|
||||
3774
app.js.bak
Normal file
3774
app.js.bak
Normal file
File diff suppressed because it is too large
Load diff
45
index.html
45
index.html
|
|
@ -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
203
index.html.bak
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Pixel Island Summoner - Local Prototype</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<canvas id="worldCanvas" aria-label="Island map"></canvas>
|
||||
|
||||
<button id="openEditor" class="edgeAdd" title="Open Pixel Studio">+</button>
|
||||
|
||||
<header class="hud topHud">
|
||||
<div>
|
||||
<div class="logo">Pixel Island</div>
|
||||
<div class="subline">Local prototype / Draw pixels, summon them to islands.</div>
|
||||
</div>
|
||||
<label class="authorCard">
|
||||
<span>Your Name</span>
|
||||
<input id="authorName" type="text" maxlength="24" value="Local Artist" />
|
||||
</label>
|
||||
<div class="clockCard" aria-label="world clock">
|
||||
<div id="phaseLabel" class="phaseLabel">Day</div>
|
||||
<div class="phaseBar"><span id="phaseBar"></span></div>
|
||||
<div class="clockHint">10 min = 1 day</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside class="hud toolHud">
|
||||
<button id="modeInspect" class="iconButton active" title="Pan / Inspect">Pan</button>
|
||||
<button id="modePlace" class="iconButton" title="Summon selected asset">Place</button>
|
||||
<button id="modeErase" class="iconButton danger" title="Remove asset">Erase</button>
|
||||
<div class="divider"></div>
|
||||
<button id="zoomOut" class="iconButton" title="Zoom out">−</button>
|
||||
<button id="zoomIn" class="iconButton" title="Zoom in">+</button>
|
||||
<button id="resetView" class="iconButton" title="Reset view">Reset</button>
|
||||
</aside>
|
||||
|
||||
<section id="studioDrawer" class="studioDrawer" aria-label="Pixel Studio">
|
||||
<div class="drawerHead">
|
||||
<div>
|
||||
<h1>Pixel Studio</h1>
|
||||
<p>Make static scenery or moving island life.</p>
|
||||
</div>
|
||||
<button id="closeEditor" class="ghostButton" title="Close">×</button>
|
||||
</div>
|
||||
|
||||
<nav class="tabs" aria-label="Studio tabs">
|
||||
<button class="tab active" data-tab="draw">Draw</button>
|
||||
<button class="tab" data-tab="library">Library</button>
|
||||
<button class="tab" data-tab="data">Data</button>
|
||||
</nav>
|
||||
|
||||
<div class="drawerBody">
|
||||
<section id="tab-draw" class="tabPanel active">
|
||||
<div class="card editorCard heroEditor">
|
||||
<div class="quickSetupBar twoCols">
|
||||
<label class="field">Canvas size
|
||||
<select id="assetSize">
|
||||
<option value="8" selected>8×8</option>
|
||||
<option value="16">16×16</option>
|
||||
<option value="32">32×32</option>
|
||||
<option value="64">64×64</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">Role
|
||||
<select id="assetCategory">
|
||||
<option value="human">Human</option>
|
||||
<option value="animal">Animal</option>
|
||||
<option value="nature" selected>Nature</option>
|
||||
<option value="building">Building</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="field compactNameField">Name
|
||||
<input id="assetName" type="text" maxlength="32" placeholder="Tiny bakery, round tree, island pup..." />
|
||||
</label>
|
||||
<div class="editorTop compactEditorTop">
|
||||
<div id="sideSwitcher" class="sideSwitcher" hidden aria-label="Sprite direction">
|
||||
<button id="editLeft" title="Left-facing sprite">◀ Left</button>
|
||||
<button id="editRight" class="active" title="Right-facing sprite is the main/front direction">▶ Right</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="paintLayout">
|
||||
<canvas id="paintCanvas" width="512" height="512" aria-label="Pixel editor"></canvas>
|
||||
<div class="palettePanel">
|
||||
<div id="paletteGrid" class="paletteGrid" aria-label="Color palette"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolRow editorToolRow">
|
||||
<button id="toolBrush" class="tool active" title="B">Draw</button>
|
||||
<button id="toolErase" class="tool" title="E">Erase</button>
|
||||
<button id="toolFill" class="tool" title="F">Fill</button>
|
||||
<button id="toolPick" class="tool" title="I">Pick</button>
|
||||
<button id="toolLine" class="tool" title="L">Line</button>
|
||||
<button id="toolRect" class="tool" title="R">Rect</button>
|
||||
<button id="toolSelect" class="tool" title="S">Select</button>
|
||||
<button id="toolDoor" class="tool buildingOnly">Door</button>
|
||||
<button id="clearPaint" class="tool danger">Clear</button>
|
||||
</div>
|
||||
<div class="toolRow editorHistoryRow">
|
||||
<button id="undoPaint" class="tool" type="button" disabled>Undo</button>
|
||||
<button id="redoPaint" class="tool" type="button" disabled>Redo</button>
|
||||
<button id="outlinePaint" class="tool" type="button">Outline</button>
|
||||
<button id="flipHorizontal" class="tool" type="button">Flip H</button>
|
||||
<button id="flipVertical" class="tool" type="button">Flip V</button>
|
||||
</div>
|
||||
<div class="toolRow editorMoveRow">
|
||||
<button id="nudgeLeft" class="tool" type="button">←</button>
|
||||
<button id="nudgeUp" class="tool" type="button">↑</button>
|
||||
<button id="nudgeDown" class="tool" type="button">↓</button>
|
||||
<button id="nudgeRight" class="tool" type="button">→</button>
|
||||
<button id="clearSelection" class="tool" type="button" disabled>Clear Sel</button>
|
||||
</div>
|
||||
<div class="toolRow editorFileRow">
|
||||
<button id="exportPng" class="tool" type="button">Export PNG</button>
|
||||
<button id="importPng" class="tool" type="button">Import PNG</button>
|
||||
<input id="pngImportInput" type="file" accept="image/png,image/*" hidden />
|
||||
</div>
|
||||
<div class="advancedRow">
|
||||
<button id="toggleAdvanced" class="tool" type="button">Advanced Draw</button>
|
||||
<button id="toolLight" class="tool advancedOnly" hidden>Light</button>
|
||||
<button id="toolDepth" class="tool advancedOnly" hidden>Depth</button>
|
||||
<span id="advancedHint" class="hint" hidden>Light/Depth tools are here. Depth paints visible blue height marks; Shift/right-click erases.</span>
|
||||
</div>
|
||||
<input id="paintColor" type="color" value="#6bd06b" hidden />
|
||||
<div id="editHint" class="hint">Palette color is used for pixels, lights, and depth marks. Shortcuts: B/E/F/I/L/R/S, Ctrl/Cmd+Z, arrows move a selection.</div>
|
||||
</div>
|
||||
|
||||
<div class="card stack summonCard">
|
||||
<div class="cardTitle">Finish</div>
|
||||
<div class="actionRow finishActions">
|
||||
<button id="saveAndPlace" class="primary bigPrimary">Save + Summon on Map</button>
|
||||
<button id="saveAsset" class="secondary">Save only</button>
|
||||
<button id="newAsset" class="secondary">New</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="lineageNote" class="lineageNote"></div>
|
||||
</section>
|
||||
|
||||
<section id="tab-library" class="tabPanel">
|
||||
<div class="card stack">
|
||||
<div class="libraryHead">
|
||||
<div class="cardTitle">Library</div>
|
||||
<button id="showHiddenAssets" class="tool" type="button">Hidden</button>
|
||||
</div>
|
||||
<p class="hint">Click an asset to select it and jump to it on the map. Copy Edit creates a new derivative.</p>
|
||||
<div id="hiddenAssetPanel" class="hiddenAssetPanel" hidden>
|
||||
<div class="cardTitle">Hidden assets</div>
|
||||
<div id="hiddenAssetList" class="assetList compactAssetList"></div>
|
||||
</div>
|
||||
<div id="assetList" class="assetList"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-data" class="tabPanel">
|
||||
<div class="card stack">
|
||||
<div class="cardTitle">Local save</div>
|
||||
<p class="hint">Saved to this browser only. Export JSON if you want to move or share a local world.</p>
|
||||
<div class="actionRow wrap">
|
||||
<button id="exportData">Export full JSON</button>
|
||||
<button id="exportCompact">Export compact</button>
|
||||
<button id="exportSnapshot">Export snapshot</button>
|
||||
<button id="exportAssetBundle">Export asset bundle</button>
|
||||
<button id="importData">Import JSON</button>
|
||||
<button id="resetAll" class="danger">Reset all</button>
|
||||
</div>
|
||||
<div id="syncStats" class="syncStats"></div>
|
||||
<textarea id="dataBox" rows="10" placeholder="Exported JSON appears here."></textarea>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="selectionBubble" class="selectionBubble" hidden>
|
||||
<div id="bubbleName" class="bubbleName">Name</div>
|
||||
<div id="bubbleAuthor" class="bubbleAuthor">by Author</div>
|
||||
<div id="bubbleRemixFrom" class="bubbleRemixFrom" hidden></div>
|
||||
<div id="bubbleRemixCount" class="bubbleRemixCount">Remixed: 0</div>
|
||||
<div class="bubbleVotes">
|
||||
<button id="voteUp" type="button">▲</button>
|
||||
<span id="voteScore">0</span>
|
||||
<button id="voteDown" type="button">▼</button>
|
||||
</div>
|
||||
<div class="bubbleActions">
|
||||
<button id="bubbleRemix" type="button">Remix</button>
|
||||
<button id="bubbleHide" type="button" hidden>Hide</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
</div>
|
||||
|
||||
<script src="./js/editor-actions.js"></script>
|
||||
<script src="./js/phase2-sync.js"></script>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -3,7 +3,40 @@
|
|||
|
||||
const root = window.PixelIslandModules ||= {};
|
||||
|
||||
function clonePixels(pixels) {
|
||||
return Array.isArray(pixels) ? [...pixels] : [];
|
||||
}
|
||||
|
||||
function floodFill(sourcePixels, size, x, y, colorCode) {
|
||||
const pixels = clonePixels(sourcePixels);
|
||||
const target = pixels[y * size + x] || null;
|
||||
const replacement = colorCode || null;
|
||||
if (target === replacement) return { pixels, changed: false, count: 0, cells: [] };
|
||||
|
||||
const stack = [[x, y]];
|
||||
const cells = [];
|
||||
while (stack.length) {
|
||||
const [cx, cy] = stack.pop();
|
||||
if (cx < 0 || cy < 0 || cx >= size || cy >= size) continue;
|
||||
const index = cy * size + cx;
|
||||
if ((pixels[index] || null) !== target) continue;
|
||||
pixels[index] = replacement;
|
||||
cells.push({ x: cx, y: cy });
|
||||
stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
|
||||
}
|
||||
return { pixels, changed: cells.length > 0, count: cells.length, cells };
|
||||
}
|
||||
|
||||
function pickColor(sourcePixels, size, x, y) {
|
||||
if (!Array.isArray(sourcePixels)) return null;
|
||||
if (x < 0 || y < 0 || x >= size || y >= size) return null;
|
||||
return sourcePixels[y * size + x] || null;
|
||||
}
|
||||
|
||||
root.EditorActions = {
|
||||
clonePixels,
|
||||
floodFill,
|
||||
pickColor,
|
||||
apply(snapshot, pushHistory, mutate) {
|
||||
pushHistory(snapshot());
|
||||
mutate();
|
||||
|
|
|
|||
402
js/phase2-sync.js
Normal file
402
js/phase2-sync.js
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
const root = window.PixelIslandModules ||= {};
|
||||
const FORMAT = 'pixel-island-phase2-compact-v1';
|
||||
const SNAPSHOT_FORMAT = 'pixel-island-phase2-snapshot-v1';
|
||||
const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v1';
|
||||
const EVENT_LOG_LIMIT = 300;
|
||||
const DB_NAME = 'pixel-island-phase2-cache';
|
||||
const DB_VERSION = 1;
|
||||
const ASSET_STORE = 'assets';
|
||||
const SNAPSHOT_STORE = 'snapshots';
|
||||
|
||||
function isCompactState(value) {
|
||||
return Boolean(value && value.format === FORMAT && Array.isArray(value.assets));
|
||||
}
|
||||
|
||||
function isAssetBundle(value) {
|
||||
return Boolean(value && value.format === ASSET_BUNDLE_FORMAT && Array.isArray(value.assets));
|
||||
}
|
||||
|
||||
function rleEncode(input) {
|
||||
const text = String(input || '');
|
||||
if (!text) return '';
|
||||
let out = '';
|
||||
let last = text[0];
|
||||
let count = 1;
|
||||
for (let i = 1; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === last) count++;
|
||||
else {
|
||||
out += `${count}:${last}`;
|
||||
last = ch;
|
||||
count = 1;
|
||||
}
|
||||
}
|
||||
out += `${count}:${last}`;
|
||||
return out;
|
||||
}
|
||||
|
||||
function rleDecode(input) {
|
||||
const text = String(input || '');
|
||||
if (!text) return '';
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
let digits = '';
|
||||
while (i < text.length && text[i] >= '0' && text[i] <= '9') digits += text[i++];
|
||||
if (text[i] !== ':') break;
|
||||
i++;
|
||||
const ch = text[i++] || '';
|
||||
const count = Math.max(0, Number(digits) || 0);
|
||||
out += ch.repeat(count);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeEncodedPlane(value, size, emptyChar = '.') {
|
||||
const total = Math.max(1, Number(size) || 1) ** 2;
|
||||
const source = typeof value === 'string' ? value : Array.isArray(value) ? value.map((v) => v || emptyChar).join('') : '';
|
||||
return (source + emptyChar.repeat(total)).slice(0, total);
|
||||
}
|
||||
|
||||
function cropPlane(encoded, size, emptyChar = '.') {
|
||||
const text = normalizeEncodedPlane(encoded, size, emptyChar);
|
||||
let minX = size;
|
||||
let minY = size;
|
||||
let maxX = -1;
|
||||
let maxY = -1;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
if (text[y * size + x] !== emptyChar) {
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxX < 0) return { b: null, e: 'raw', v: '' };
|
||||
const w = maxX - minX + 1;
|
||||
const h = maxY - minY + 1;
|
||||
let cropped = '';
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
cropped += text.slice(y * size + minX, y * size + minX + w);
|
||||
}
|
||||
const rle = rleEncode(cropped);
|
||||
return rle.length < cropped.length ? { b: [minX, minY, w, h], e: 'rle', v: rle } : { b: [minX, minY, w, h], e: 'raw', v: cropped };
|
||||
}
|
||||
|
||||
function expandPlane(packed, size, emptyChar = '.') {
|
||||
const total = Math.max(1, Number(size) || 1) ** 2;
|
||||
const out = Array(total).fill(emptyChar);
|
||||
if (!packed || !packed.b) return out.join('');
|
||||
const [x0, y0, w, h] = packed.b.map((v) => Math.max(0, Number(v) || 0));
|
||||
const value = packed.e === 'rle' ? rleDecode(packed.v) : String(packed.v || '');
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const src = y * w + x;
|
||||
const dx = x0 + x;
|
||||
const dy = y0 + y;
|
||||
if (dx >= 0 && dy >= 0 && dx < size && dy < size && src < value.length) {
|
||||
out[dy * size + dx] = value[src] || emptyChar;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
function packAsset(asset) {
|
||||
const size = Math.max(1, Number(asset.size) || 16);
|
||||
const category = asset.category === 'dynamic' ? 'dynamic' : 'static';
|
||||
const right = normalizeEncodedPlane(asset.faces?.right || asset.pixels || '', size, '.');
|
||||
const depth = asset.meta?.depthPixels ? normalizeEncodedPlane(asset.meta.depthPixels, size, '.') : '';
|
||||
const lights = Array.isArray(asset.meta?.lightPixels)
|
||||
? asset.meta.lightPixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || asset.meta?.lightColor || '']).filter((p) => p[2])
|
||||
: [];
|
||||
const meta = {};
|
||||
if (depth && /[1\-]/.test(depth)) meta.d = cropPlane(depth, size, '.');
|
||||
if (lights.length) meta.l = lights;
|
||||
if (asset.meta?.lightColor) meta.lc = asset.meta.lightColor;
|
||||
if (asset.meta?.door) meta.dr = [Number(asset.meta.door.x) || 0, Number(asset.meta.door.y) || 0];
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
h: asset.contentHash || asset.hash || null,
|
||||
n: asset.name || 'Untitled',
|
||||
c: category,
|
||||
t: asset.subtype || (category === 'dynamic' ? 'human' : 'other'),
|
||||
s: size,
|
||||
p: cropPlane(right, size, '.'),
|
||||
f: category === 'dynamic' ? { l: 'mirror' } : null,
|
||||
pa: asset.parentAssetId || null,
|
||||
oa: asset.originalAssetId || null,
|
||||
ca: asset.createdAt || Date.now(),
|
||||
ua: asset.updatedAt || asset.createdAt || Date.now(),
|
||||
au: asset.author || 'Local Artist',
|
||||
m: Object.keys(meta).length ? meta : null
|
||||
};
|
||||
}
|
||||
|
||||
function unpackAsset(packed) {
|
||||
if (!packed || !packed.id) return null;
|
||||
const size = Math.max(1, Number(packed.s || packed.size) || 16);
|
||||
const category = packed.c === 'dynamic' || packed.category === 'dynamic' ? 'dynamic' : 'static';
|
||||
const pixels = expandPlane(packed.p, size, '.');
|
||||
const metaPacked = packed.m || {};
|
||||
const lightPixels = Array.isArray(metaPacked.l)
|
||||
? metaPacked.l.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || metaPacked.lc || 'a' }))
|
||||
: [];
|
||||
const meta = {
|
||||
hasLight: lightPixels.length > 0,
|
||||
lightPixels,
|
||||
lightColor: lightPixels.length > 0 ? (metaPacked.lc || lightPixels[0]?.c || null) : null,
|
||||
depthPixels: metaPacked.d ? expandPlane(metaPacked.d, size, '.') : null,
|
||||
door: Array.isArray(metaPacked.dr) ? { x: Number(metaPacked.dr[0]) || 0, y: Number(metaPacked.dr[1]) || 0 } : null
|
||||
};
|
||||
return {
|
||||
id: packed.id,
|
||||
name: packed.n || 'Untitled',
|
||||
category,
|
||||
subtype: packed.t || (category === 'dynamic' ? 'human' : 'other'),
|
||||
size,
|
||||
pixels,
|
||||
faces: category === 'dynamic' ? { right: pixels, left: 'mirror' } : null,
|
||||
parentAssetId: packed.pa || null,
|
||||
originalAssetId: packed.oa || null,
|
||||
createdAt: packed.ca || Date.now(),
|
||||
updatedAt: packed.ua || packed.ca || Date.now(),
|
||||
author: packed.au || 'Local Artist',
|
||||
meta,
|
||||
contentHash: packed.h || null
|
||||
};
|
||||
}
|
||||
|
||||
function packPlacement(item) {
|
||||
return [item.id, item.assetId, Number(item.x) || 0, Number(item.y) || 0, item.placedAt || Date.now(), Number(item.version) || 1];
|
||||
}
|
||||
|
||||
function unpackPlacement(row) {
|
||||
if (!Array.isArray(row)) return row;
|
||||
return { id: row[0], assetId: row[1], x: row[2], y: row[3], placedAt: row[4], version: row[5] || 1 };
|
||||
}
|
||||
|
||||
function packDynamic(item) {
|
||||
return [item.id, item.assetId, Number(item.homeX) || 0, Number(item.homeY) || 0, item.createdAt || Date.now(), Number(item.version) || 1];
|
||||
}
|
||||
|
||||
function unpackDynamic(row) {
|
||||
if (!Array.isArray(row)) return row;
|
||||
return { id: row[0], assetId: row[1], homeX: row[2], homeY: row[3], createdAt: row[4], version: row[5] || 1 };
|
||||
}
|
||||
|
||||
function compactState(state) {
|
||||
return {
|
||||
schema: 3,
|
||||
format: FORMAT,
|
||||
authorName: state.authorName || 'Local Artist',
|
||||
assets: Array.isArray(state.assets) ? state.assets.map(packAsset) : [],
|
||||
placed: Array.isArray(state.placed) ? state.placed.map(packPlacement) : [],
|
||||
dynamicSummons: Array.isArray(state.dynamicSummons) ? state.dynamicSummons.map(packDynamic) : [],
|
||||
objectVotes: state.objectVotes || {},
|
||||
assetVotes: state.assetVotes || {},
|
||||
hiddenAssets: state.hiddenAssets || {},
|
||||
hiddenObjects: state.hiddenObjects || {},
|
||||
eventLog: Array.isArray(state.eventLog) ? state.eventLog.slice(-EVENT_LOG_LIMIT) : [],
|
||||
sync: state.sync || { lastEventId: null }
|
||||
};
|
||||
}
|
||||
|
||||
function expandState(input) {
|
||||
if (!isCompactState(input)) return input;
|
||||
return {
|
||||
schema: 3,
|
||||
authorName: input.authorName || 'Local Artist',
|
||||
assets: input.assets.map(unpackAsset).filter(Boolean),
|
||||
placed: (input.placed || []).map(unpackPlacement),
|
||||
dynamicSummons: (input.dynamicSummons || []).map(unpackDynamic),
|
||||
objectVotes: input.objectVotes || {},
|
||||
assetVotes: input.assetVotes || {},
|
||||
hiddenAssets: input.hiddenAssets || {},
|
||||
hiddenObjects: input.hiddenObjects || {},
|
||||
eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-EVENT_LOG_LIMIT) : [],
|
||||
sync: input.sync || { lastEventId: null }
|
||||
};
|
||||
}
|
||||
|
||||
function compactSizeReport(state) {
|
||||
const full = JSON.stringify({ ...state, schema: 3 });
|
||||
const compact = JSON.stringify(compactState(state));
|
||||
return {
|
||||
fullBytes: full.length,
|
||||
compactBytes: compact.length,
|
||||
savedBytes: Math.max(0, full.length - compact.length),
|
||||
savedPercent: full.length ? Math.round((1 - compact.length / full.length) * 1000) / 10 : 0,
|
||||
assets: state.assets?.length || 0,
|
||||
objects: (state.placed?.length || 0) + (state.dynamicSummons?.length || 0)
|
||||
};
|
||||
}
|
||||
|
||||
function assetManifest(state) {
|
||||
return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, s: asset.size, c: asset.category, t: asset.subtype }));
|
||||
}
|
||||
|
||||
function makeSnapshot(state, worldId = 'local-main') {
|
||||
return {
|
||||
schema: 3,
|
||||
format: SNAPSHOT_FORMAT,
|
||||
worldId,
|
||||
createdAt: Date.now(),
|
||||
manifest: assetManifest(state),
|
||||
placed: (state.placed || []).map(packPlacement),
|
||||
dynamicSummons: (state.dynamicSummons || []).map(packDynamic),
|
||||
hiddenObjects: state.hiddenObjects || {}
|
||||
};
|
||||
}
|
||||
|
||||
function makeAssetBundle(state, assetIds) {
|
||||
const wanted = new Set(assetIds || []);
|
||||
const assets = (state.assets || []).filter((asset) => !wanted.size || wanted.has(asset.id)).map(packAsset);
|
||||
return { schema: 3, format: ASSET_BUNDLE_FORMAT, createdAt: Date.now(), assets };
|
||||
}
|
||||
|
||||
function unpackAssetBundle(bundle) {
|
||||
if (!isAssetBundle(bundle)) return [];
|
||||
return bundle.assets.map(unpackAsset).filter(Boolean);
|
||||
}
|
||||
|
||||
function findMissingAssetIds(snapshot, knownAssetIds) {
|
||||
const known = new Set(knownAssetIds || []);
|
||||
return (snapshot?.manifest || []).map((asset) => asset.id).filter((id) => id && !known.has(id));
|
||||
}
|
||||
|
||||
function makeEvent(type, payload = {}) {
|
||||
return { id: `ev_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, type, at: Date.now(), ...payload };
|
||||
}
|
||||
|
||||
function createAssetUpsertEvent(asset) {
|
||||
return makeEvent('asset.upsert', { asset: packAsset(asset) });
|
||||
}
|
||||
|
||||
function createObjectUpsertEvent(kind, object) {
|
||||
return makeEvent('object.upsert', { kind, object: kind === 'dynamic' ? packDynamic(object) : packPlacement(object) });
|
||||
}
|
||||
|
||||
function createObjectDeleteEvent(kind, objectId) {
|
||||
return makeEvent('object.delete', { kind, objectId });
|
||||
}
|
||||
|
||||
function applyEvent(state, event) {
|
||||
if (!state || !event) return state;
|
||||
if (event.type === 'asset.upsert' && event.asset) {
|
||||
const asset = unpackAsset(event.asset);
|
||||
if (!asset) return state;
|
||||
const index = (state.assets || []).findIndex((a) => a.id === asset.id);
|
||||
if (index >= 0) state.assets[index] = asset;
|
||||
else (state.assets ||= []).unshift(asset);
|
||||
}
|
||||
if (event.type === 'object.upsert') {
|
||||
if (event.kind === 'dynamic') {
|
||||
const object = unpackDynamic(event.object);
|
||||
const list = state.dynamicSummons ||= [];
|
||||
const index = list.findIndex((item) => item.id === object.id);
|
||||
if (index >= 0) list[index] = object;
|
||||
else list.push(object);
|
||||
} else {
|
||||
const object = unpackPlacement(event.object);
|
||||
const list = state.placed ||= [];
|
||||
const index = list.findIndex((item) => item.id === object.id);
|
||||
if (index >= 0) list[index] = object;
|
||||
else list.push(object);
|
||||
}
|
||||
}
|
||||
if (event.type === 'object.delete') {
|
||||
const key = event.kind === 'dynamic' ? 'dynamicSummons' : 'placed';
|
||||
state[key] = (state[key] || []).filter((item) => item.id !== event.objectId);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function openDb() {
|
||||
if (!('indexedDB' in window)) return Promise.reject(new Error('IndexedDB is not available.'));
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(ASSET_STORE)) db.createObjectStore(ASSET_STORE, { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains(SNAPSHOT_STORE)) db.createObjectStore(SNAPSHOT_STORE, { keyPath: 'worldId' });
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function cacheAssets(assets) {
|
||||
if (!Array.isArray(assets) || !assets.length) return;
|
||||
const db = await openDb();
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(ASSET_STORE, 'readwrite');
|
||||
for (const asset of assets) tx.objectStore(ASSET_STORE).put(packAsset(asset));
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function readCachedAssets(assetIds) {
|
||||
const ids = Array.from(assetIds || []);
|
||||
if (!ids.length) return [];
|
||||
const db = await openDb();
|
||||
const rows = await Promise.all(ids.map((id) => new Promise((resolve) => {
|
||||
const request = db.transaction(ASSET_STORE, 'readonly').objectStore(ASSET_STORE).get(id);
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = () => resolve(null);
|
||||
})));
|
||||
db.close();
|
||||
return rows.filter(Boolean).map(unpackAsset).filter(Boolean);
|
||||
}
|
||||
|
||||
async function cacheSnapshot(snapshot) {
|
||||
if (!snapshot?.worldId) return;
|
||||
const db = await openDb();
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(SNAPSHOT_STORE, 'readwrite');
|
||||
tx.objectStore(SNAPSHOT_STORE).put(snapshot);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
db.close();
|
||||
}
|
||||
|
||||
root.Phase2Sync = {
|
||||
FORMAT,
|
||||
SNAPSHOT_FORMAT,
|
||||
ASSET_BUNDLE_FORMAT,
|
||||
EVENT_LOG_LIMIT,
|
||||
isCompactState,
|
||||
isAssetBundle,
|
||||
rleEncode,
|
||||
rleDecode,
|
||||
cropPlane,
|
||||
expandPlane,
|
||||
packAsset,
|
||||
unpackAsset,
|
||||
compactState,
|
||||
expandState,
|
||||
compactSizeReport,
|
||||
assetManifest,
|
||||
makeSnapshot,
|
||||
makeAssetBundle,
|
||||
unpackAssetBundle,
|
||||
findMissingAssetIds,
|
||||
makeEvent,
|
||||
createAssetUpsertEvent,
|
||||
createObjectUpsertEvent,
|
||||
createObjectDeleteEvent,
|
||||
applyEvent,
|
||||
cacheAssets,
|
||||
readCachedAssets,
|
||||
cacheSnapshot
|
||||
};
|
||||
})();
|
||||
402
js/phase2-sync.js.bak
Normal file
402
js/phase2-sync.js.bak
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
const root = window.PixelIslandModules ||= {};
|
||||
const FORMAT = 'pixel-island-phase2-compact-v1';
|
||||
const SNAPSHOT_FORMAT = 'pixel-island-phase2-snapshot-v1';
|
||||
const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v1';
|
||||
const EVENT_LOG_LIMIT = 300;
|
||||
const DB_NAME = 'pixel-island-phase2-cache';
|
||||
const DB_VERSION = 1;
|
||||
const ASSET_STORE = 'assets';
|
||||
const SNAPSHOT_STORE = 'snapshots';
|
||||
|
||||
function isCompactState(value) {
|
||||
return Boolean(value && value.format === FORMAT && Array.isArray(value.assets));
|
||||
}
|
||||
|
||||
function isAssetBundle(value) {
|
||||
return Boolean(value && value.format === ASSET_BUNDLE_FORMAT && Array.isArray(value.assets));
|
||||
}
|
||||
|
||||
function rleEncode(input) {
|
||||
const text = String(input || '');
|
||||
if (!text) return '';
|
||||
let out = '';
|
||||
let last = text[0];
|
||||
let count = 1;
|
||||
for (let i = 1; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === last) count++;
|
||||
else {
|
||||
out += `${count}:${last}`;
|
||||
last = ch;
|
||||
count = 1;
|
||||
}
|
||||
}
|
||||
out += `${count}:${last}`;
|
||||
return out;
|
||||
}
|
||||
|
||||
function rleDecode(input) {
|
||||
const text = String(input || '');
|
||||
if (!text) return '';
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
let digits = '';
|
||||
while (i < text.length && text[i] >= '0' && text[i] <= '9') digits += text[i++];
|
||||
if (text[i] !== ':') break;
|
||||
i++;
|
||||
const ch = text[i++] || '';
|
||||
const count = Math.max(0, Number(digits) || 0);
|
||||
out += ch.repeat(count);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeEncodedPlane(value, size, emptyChar = '.') {
|
||||
const total = Math.max(1, Number(size) || 1) ** 2;
|
||||
const source = typeof value === 'string' ? value : Array.isArray(value) ? value.map((v) => v || emptyChar).join('') : '';
|
||||
return (source + emptyChar.repeat(total)).slice(0, total);
|
||||
}
|
||||
|
||||
function cropPlane(encoded, size, emptyChar = '.') {
|
||||
const text = normalizeEncodedPlane(encoded, size, emptyChar);
|
||||
let minX = size;
|
||||
let minY = size;
|
||||
let maxX = -1;
|
||||
let maxY = -1;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
if (text[y * size + x] !== emptyChar) {
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxX < 0) return { b: null, e: 'raw', v: '' };
|
||||
const w = maxX - minX + 1;
|
||||
const h = maxY - minY + 1;
|
||||
let cropped = '';
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
cropped += text.slice(y * size + minX, y * size + minX + w);
|
||||
}
|
||||
const rle = rleEncode(cropped);
|
||||
return rle.length < cropped.length ? { b: [minX, minY, w, h], e: 'rle', v: rle } : { b: [minX, minY, w, h], e: 'raw', v: cropped };
|
||||
}
|
||||
|
||||
function expandPlane(packed, size, emptyChar = '.') {
|
||||
const total = Math.max(1, Number(size) || 1) ** 2;
|
||||
const out = Array(total).fill(emptyChar);
|
||||
if (!packed || !packed.b) return out.join('');
|
||||
const [x0, y0, w, h] = packed.b.map((v) => Math.max(0, Number(v) || 0));
|
||||
const value = packed.e === 'rle' ? rleDecode(packed.v) : String(packed.v || '');
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const src = y * w + x;
|
||||
const dx = x0 + x;
|
||||
const dy = y0 + y;
|
||||
if (dx >= 0 && dy >= 0 && dx < size && dy < size && src < value.length) {
|
||||
out[dy * size + dx] = value[src] || emptyChar;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
function packAsset(asset) {
|
||||
const size = Math.max(1, Number(asset.size) || 16);
|
||||
const category = asset.category === 'dynamic' ? 'dynamic' : 'static';
|
||||
const right = normalizeEncodedPlane(asset.faces?.right || asset.pixels || '', size, '.');
|
||||
const depth = asset.meta?.depthPixels ? normalizeEncodedPlane(asset.meta.depthPixels, size, '.') : '';
|
||||
const lights = Array.isArray(asset.meta?.lightPixels)
|
||||
? asset.meta.lightPixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || asset.meta?.lightColor || '']).filter((p) => p[2])
|
||||
: [];
|
||||
const meta = {};
|
||||
if (depth && /1/.test(depth)) meta.d = cropPlane(depth, size, '.');
|
||||
if (lights.length) meta.l = lights;
|
||||
if (asset.meta?.lightColor) meta.lc = asset.meta.lightColor;
|
||||
if (asset.meta?.door) meta.dr = [Number(asset.meta.door.x) || 0, Number(asset.meta.door.y) || 0];
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
h: asset.contentHash || asset.hash || null,
|
||||
n: asset.name || 'Untitled',
|
||||
c: category,
|
||||
t: asset.subtype || (category === 'dynamic' ? 'human' : 'other'),
|
||||
s: size,
|
||||
p: cropPlane(right, size, '.'),
|
||||
f: category === 'dynamic' ? { l: 'mirror' } : null,
|
||||
pa: asset.parentAssetId || null,
|
||||
oa: asset.originalAssetId || null,
|
||||
ca: asset.createdAt || Date.now(),
|
||||
ua: asset.updatedAt || asset.createdAt || Date.now(),
|
||||
au: asset.author || 'Local Artist',
|
||||
m: Object.keys(meta).length ? meta : null
|
||||
};
|
||||
}
|
||||
|
||||
function unpackAsset(packed) {
|
||||
if (!packed || !packed.id) return null;
|
||||
const size = Math.max(1, Number(packed.s || packed.size) || 16);
|
||||
const category = packed.c === 'dynamic' || packed.category === 'dynamic' ? 'dynamic' : 'static';
|
||||
const pixels = expandPlane(packed.p, size, '.');
|
||||
const metaPacked = packed.m || {};
|
||||
const lightPixels = Array.isArray(metaPacked.l)
|
||||
? metaPacked.l.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || metaPacked.lc || 'a' }))
|
||||
: [];
|
||||
const meta = {
|
||||
hasLight: lightPixels.length > 0,
|
||||
lightPixels,
|
||||
lightColor: lightPixels.length > 0 ? (metaPacked.lc || lightPixels[0]?.c || null) : null,
|
||||
depthPixels: metaPacked.d ? expandPlane(metaPacked.d, size, '.') : null,
|
||||
door: Array.isArray(metaPacked.dr) ? { x: Number(metaPacked.dr[0]) || 0, y: Number(metaPacked.dr[1]) || 0 } : null
|
||||
};
|
||||
return {
|
||||
id: packed.id,
|
||||
name: packed.n || 'Untitled',
|
||||
category,
|
||||
subtype: packed.t || (category === 'dynamic' ? 'human' : 'other'),
|
||||
size,
|
||||
pixels,
|
||||
faces: category === 'dynamic' ? { right: pixels, left: 'mirror' } : null,
|
||||
parentAssetId: packed.pa || null,
|
||||
originalAssetId: packed.oa || null,
|
||||
createdAt: packed.ca || Date.now(),
|
||||
updatedAt: packed.ua || packed.ca || Date.now(),
|
||||
author: packed.au || 'Local Artist',
|
||||
meta,
|
||||
contentHash: packed.h || null
|
||||
};
|
||||
}
|
||||
|
||||
function packPlacement(item) {
|
||||
return [item.id, item.assetId, Number(item.x) || 0, Number(item.y) || 0, item.placedAt || Date.now(), Number(item.version) || 1];
|
||||
}
|
||||
|
||||
function unpackPlacement(row) {
|
||||
if (!Array.isArray(row)) return row;
|
||||
return { id: row[0], assetId: row[1], x: row[2], y: row[3], placedAt: row[4], version: row[5] || 1 };
|
||||
}
|
||||
|
||||
function packDynamic(item) {
|
||||
return [item.id, item.assetId, Number(item.homeX) || 0, Number(item.homeY) || 0, item.createdAt || Date.now(), Number(item.version) || 1];
|
||||
}
|
||||
|
||||
function unpackDynamic(row) {
|
||||
if (!Array.isArray(row)) return row;
|
||||
return { id: row[0], assetId: row[1], homeX: row[2], homeY: row[3], createdAt: row[4], version: row[5] || 1 };
|
||||
}
|
||||
|
||||
function compactState(state) {
|
||||
return {
|
||||
schema: 3,
|
||||
format: FORMAT,
|
||||
authorName: state.authorName || 'Local Artist',
|
||||
assets: Array.isArray(state.assets) ? state.assets.map(packAsset) : [],
|
||||
placed: Array.isArray(state.placed) ? state.placed.map(packPlacement) : [],
|
||||
dynamicSummons: Array.isArray(state.dynamicSummons) ? state.dynamicSummons.map(packDynamic) : [],
|
||||
objectVotes: state.objectVotes || {},
|
||||
assetVotes: state.assetVotes || {},
|
||||
hiddenAssets: state.hiddenAssets || {},
|
||||
hiddenObjects: state.hiddenObjects || {},
|
||||
eventLog: Array.isArray(state.eventLog) ? state.eventLog.slice(-EVENT_LOG_LIMIT) : [],
|
||||
sync: state.sync || { lastEventId: null }
|
||||
};
|
||||
}
|
||||
|
||||
function expandState(input) {
|
||||
if (!isCompactState(input)) return input;
|
||||
return {
|
||||
schema: 3,
|
||||
authorName: input.authorName || 'Local Artist',
|
||||
assets: input.assets.map(unpackAsset).filter(Boolean),
|
||||
placed: (input.placed || []).map(unpackPlacement),
|
||||
dynamicSummons: (input.dynamicSummons || []).map(unpackDynamic),
|
||||
objectVotes: input.objectVotes || {},
|
||||
assetVotes: input.assetVotes || {},
|
||||
hiddenAssets: input.hiddenAssets || {},
|
||||
hiddenObjects: input.hiddenObjects || {},
|
||||
eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-EVENT_LOG_LIMIT) : [],
|
||||
sync: input.sync || { lastEventId: null }
|
||||
};
|
||||
}
|
||||
|
||||
function compactSizeReport(state) {
|
||||
const full = JSON.stringify({ ...state, schema: 3 });
|
||||
const compact = JSON.stringify(compactState(state));
|
||||
return {
|
||||
fullBytes: full.length,
|
||||
compactBytes: compact.length,
|
||||
savedBytes: Math.max(0, full.length - compact.length),
|
||||
savedPercent: full.length ? Math.round((1 - compact.length / full.length) * 1000) / 10 : 0,
|
||||
assets: state.assets?.length || 0,
|
||||
objects: (state.placed?.length || 0) + (state.dynamicSummons?.length || 0)
|
||||
};
|
||||
}
|
||||
|
||||
function assetManifest(state) {
|
||||
return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, s: asset.size, c: asset.category, t: asset.subtype }));
|
||||
}
|
||||
|
||||
function makeSnapshot(state, worldId = 'local-main') {
|
||||
return {
|
||||
schema: 3,
|
||||
format: SNAPSHOT_FORMAT,
|
||||
worldId,
|
||||
createdAt: Date.now(),
|
||||
manifest: assetManifest(state),
|
||||
placed: (state.placed || []).map(packPlacement),
|
||||
dynamicSummons: (state.dynamicSummons || []).map(packDynamic),
|
||||
hiddenObjects: state.hiddenObjects || {}
|
||||
};
|
||||
}
|
||||
|
||||
function makeAssetBundle(state, assetIds) {
|
||||
const wanted = new Set(assetIds || []);
|
||||
const assets = (state.assets || []).filter((asset) => !wanted.size || wanted.has(asset.id)).map(packAsset);
|
||||
return { schema: 3, format: ASSET_BUNDLE_FORMAT, createdAt: Date.now(), assets };
|
||||
}
|
||||
|
||||
function unpackAssetBundle(bundle) {
|
||||
if (!isAssetBundle(bundle)) return [];
|
||||
return bundle.assets.map(unpackAsset).filter(Boolean);
|
||||
}
|
||||
|
||||
function findMissingAssetIds(snapshot, knownAssetIds) {
|
||||
const known = new Set(knownAssetIds || []);
|
||||
return (snapshot?.manifest || []).map((asset) => asset.id).filter((id) => id && !known.has(id));
|
||||
}
|
||||
|
||||
function makeEvent(type, payload = {}) {
|
||||
return { id: `ev_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, type, at: Date.now(), ...payload };
|
||||
}
|
||||
|
||||
function createAssetUpsertEvent(asset) {
|
||||
return makeEvent('asset.upsert', { asset: packAsset(asset) });
|
||||
}
|
||||
|
||||
function createObjectUpsertEvent(kind, object) {
|
||||
return makeEvent('object.upsert', { kind, object: kind === 'dynamic' ? packDynamic(object) : packPlacement(object) });
|
||||
}
|
||||
|
||||
function createObjectDeleteEvent(kind, objectId) {
|
||||
return makeEvent('object.delete', { kind, objectId });
|
||||
}
|
||||
|
||||
function applyEvent(state, event) {
|
||||
if (!state || !event) return state;
|
||||
if (event.type === 'asset.upsert' && event.asset) {
|
||||
const asset = unpackAsset(event.asset);
|
||||
if (!asset) return state;
|
||||
const index = (state.assets || []).findIndex((a) => a.id === asset.id);
|
||||
if (index >= 0) state.assets[index] = asset;
|
||||
else (state.assets ||= []).unshift(asset);
|
||||
}
|
||||
if (event.type === 'object.upsert') {
|
||||
if (event.kind === 'dynamic') {
|
||||
const object = unpackDynamic(event.object);
|
||||
const list = state.dynamicSummons ||= [];
|
||||
const index = list.findIndex((item) => item.id === object.id);
|
||||
if (index >= 0) list[index] = object;
|
||||
else list.push(object);
|
||||
} else {
|
||||
const object = unpackPlacement(event.object);
|
||||
const list = state.placed ||= [];
|
||||
const index = list.findIndex((item) => item.id === object.id);
|
||||
if (index >= 0) list[index] = object;
|
||||
else list.push(object);
|
||||
}
|
||||
}
|
||||
if (event.type === 'object.delete') {
|
||||
const key = event.kind === 'dynamic' ? 'dynamicSummons' : 'placed';
|
||||
state[key] = (state[key] || []).filter((item) => item.id !== event.objectId);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function openDb() {
|
||||
if (!('indexedDB' in window)) return Promise.reject(new Error('IndexedDB is not available.'));
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(ASSET_STORE)) db.createObjectStore(ASSET_STORE, { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains(SNAPSHOT_STORE)) db.createObjectStore(SNAPSHOT_STORE, { keyPath: 'worldId' });
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function cacheAssets(assets) {
|
||||
if (!Array.isArray(assets) || !assets.length) return;
|
||||
const db = await openDb();
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(ASSET_STORE, 'readwrite');
|
||||
for (const asset of assets) tx.objectStore(ASSET_STORE).put(packAsset(asset));
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function readCachedAssets(assetIds) {
|
||||
const ids = Array.from(assetIds || []);
|
||||
if (!ids.length) return [];
|
||||
const db = await openDb();
|
||||
const rows = await Promise.all(ids.map((id) => new Promise((resolve) => {
|
||||
const request = db.transaction(ASSET_STORE, 'readonly').objectStore(ASSET_STORE).get(id);
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = () => resolve(null);
|
||||
})));
|
||||
db.close();
|
||||
return rows.filter(Boolean).map(unpackAsset).filter(Boolean);
|
||||
}
|
||||
|
||||
async function cacheSnapshot(snapshot) {
|
||||
if (!snapshot?.worldId) return;
|
||||
const db = await openDb();
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(SNAPSHOT_STORE, 'readwrite');
|
||||
tx.objectStore(SNAPSHOT_STORE).put(snapshot);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
db.close();
|
||||
}
|
||||
|
||||
root.Phase2Sync = {
|
||||
FORMAT,
|
||||
SNAPSHOT_FORMAT,
|
||||
ASSET_BUNDLE_FORMAT,
|
||||
EVENT_LOG_LIMIT,
|
||||
isCompactState,
|
||||
isAssetBundle,
|
||||
rleEncode,
|
||||
rleDecode,
|
||||
cropPlane,
|
||||
expandPlane,
|
||||
packAsset,
|
||||
unpackAsset,
|
||||
compactState,
|
||||
expandState,
|
||||
compactSizeReport,
|
||||
assetManifest,
|
||||
makeSnapshot,
|
||||
makeAssetBundle,
|
||||
unpackAssetBundle,
|
||||
findMissingAssetIds,
|
||||
makeEvent,
|
||||
createAssetUpsertEvent,
|
||||
createObjectUpsertEvent,
|
||||
createObjectDeleteEvent,
|
||||
applyEvent,
|
||||
cacheAssets,
|
||||
readCachedAssets,
|
||||
cacheSnapshot
|
||||
};
|
||||
})();
|
||||
22
styles.css
22
styles.css
|
|
@ -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
901
styles.css.bak
Normal file
|
|
@ -0,0 +1,901 @@
|
|||
:root {
|
||||
--ink: #243044;
|
||||
--muted: #6f7b91;
|
||||
--paper: #fff7e8;
|
||||
--panel: rgba(255, 248, 232, .94);
|
||||
--panel-solid: #fff8e9;
|
||||
--panel-2: #ffeec9;
|
||||
--line: #2d3c50;
|
||||
--line-soft: rgba(45, 60, 80, .18);
|
||||
--pink: #ff85b3;
|
||||
--pink-dark: #f05f98;
|
||||
--mint: #73d6a4;
|
||||
--sky: #77c9ff;
|
||||
--sun: #ffd66e;
|
||||
--danger: #ff6b6b;
|
||||
--shadow: 6px 6px 0 rgba(34, 45, 63, .14);
|
||||
--hard-shadow: 4px 4px 0 rgba(34, 45, 63, .22);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
background: #92d8ff;
|
||||
font-family: "Courier New", "Monaco", "Lucida Console", ui-monospace, monospace;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { -webkit-tap-highlight-color: transparent; }
|
||||
|
||||
.app {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#worldCanvas {
|
||||
display: block;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
image-rendering: pixelated;
|
||||
cursor: grab;
|
||||
background: #8cd4ff;
|
||||
}
|
||||
|
||||
#worldCanvas.dragging { cursor: grabbing; }
|
||||
#worldCanvas.placeCursor { cursor: copy; }
|
||||
#worldCanvas.eraseCursor { cursor: not-allowed; }
|
||||
|
||||
.hud {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
background: var(--panel);
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 0;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.topHud {
|
||||
top: 14px;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
min-height: 70px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-weight: 900;
|
||||
letter-spacing: .04em;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.logo span {
|
||||
display: inline-block;
|
||||
background: var(--pink);
|
||||
border: 2px solid var(--line);
|
||||
padding: 1px 6px;
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
.subline {
|
||||
margin-top: 3px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
.authorCard {
|
||||
width: 190px;
|
||||
border: 2px solid var(--line);
|
||||
background: #fffdf5;
|
||||
padding: 6px 8px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.authorCard span {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.authorCard input {
|
||||
margin-top: 3px;
|
||||
padding: 5px 6px;
|
||||
border-width: 2px;
|
||||
background: #fffaf0;
|
||||
}
|
||||
|
||||
.clockCard {
|
||||
width: 230px;
|
||||
border: 2px solid var(--line);
|
||||
background: #fffdf5;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.phaseLabel {
|
||||
font-weight: 900;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.phaseBar {
|
||||
margin-top: 6px;
|
||||
height: 10px;
|
||||
background: #dfe8f0;
|
||||
border: 2px solid var(--line);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.phaseBar span {
|
||||
display: block;
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #ffd66e, #77c9ff, #6862e8);
|
||||
}
|
||||
|
||||
.clockHint {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.edgeAdd {
|
||||
position: absolute;
|
||||
z-index: 8;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 56px;
|
||||
height: 86px;
|
||||
transform: translateY(-50%);
|
||||
border: 3px solid var(--line);
|
||||
border-left: 0;
|
||||
border-radius: 0 18px 18px 0;
|
||||
background: var(--pink);
|
||||
color: white;
|
||||
font-size: 42px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
box-shadow: var(--hard-shadow);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edgeAdd:hover { background: var(--pink-dark); }
|
||||
.edgeAdd:active { transform: translateY(calc(-50% + 2px)); box-shadow: 2px 2px 0 rgba(34,45,63,.22); }
|
||||
|
||||
.toolHud {
|
||||
right: 14px;
|
||||
top: 116px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 7px;
|
||||
padding: 9px;
|
||||
}
|
||||
|
||||
.iconButton,
|
||||
button,
|
||||
.ghostButton,
|
||||
.tab,
|
||||
.tool,
|
||||
.segmented button {
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 0;
|
||||
background: #fffdf5;
|
||||
color: var(--ink);
|
||||
padding: 8px 11px;
|
||||
font-weight: 850;
|
||||
cursor: pointer;
|
||||
box-shadow: 3px 3px 0 rgba(36, 48, 68, .16);
|
||||
transition: transform .05s, box-shadow .05s, background .12s;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
.ghostButton:hover,
|
||||
.tab:hover,
|
||||
.tool:hover,
|
||||
.segmented button:hover { background: #fff1c8; }
|
||||
|
||||
button:active,
|
||||
.ghostButton:active,
|
||||
.tab:active,
|
||||
.tool:active,
|
||||
.segmented button:active {
|
||||
transform: translate(2px, 2px);
|
||||
box-shadow: 1px 1px 0 rgba(36, 48, 68, .16);
|
||||
}
|
||||
|
||||
button.active,
|
||||
.tab.active,
|
||||
.tool.active,
|
||||
.segmented button.active {
|
||||
background: var(--sun);
|
||||
box-shadow: inset 0 0 0 2px rgba(255,255,255,.4), 3px 3px 0 rgba(36,48,68,.18);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--mint);
|
||||
color: #163a2a;
|
||||
}
|
||||
|
||||
button.secondary { background: var(--panel-2); }
|
||||
button.danger, .tool.danger, .iconButton.danger { background: #ffe4e4; color: #833232; }
|
||||
button.danger.active, .iconButton.danger.active { background: var(--danger); color: #fffdf5; }
|
||||
|
||||
.divider {
|
||||
height: 2px;
|
||||
background: var(--line);
|
||||
opacity: .22;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.infoHud {
|
||||
right: 14px;
|
||||
bottom: 14px;
|
||||
width: min(360px, calc(100vw - 28px));
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.selectedAsset {
|
||||
font-weight: 900;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px dashed rgba(45,60,80,.25);
|
||||
}
|
||||
|
||||
.tileInfo {
|
||||
white-space: pre-wrap;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.studioDrawer {
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: min(500px, calc(100vw - 20px));
|
||||
height: 100vh;
|
||||
background: var(--panel-solid);
|
||||
border-right: 3px solid var(--line);
|
||||
box-shadow: 10px 0 0 rgba(36, 48, 68, .12);
|
||||
transform: translateX(-104%);
|
||||
transition: transform .18s ease-out;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
}
|
||||
|
||||
.studioDrawer.open { transform: translateX(0); }
|
||||
|
||||
.drawerHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px 8px;
|
||||
border-bottom: 3px solid var(--line);
|
||||
background: #ffe6f0;
|
||||
}
|
||||
|
||||
.drawerHead h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.drawerHead p {
|
||||
margin: 3px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ghostButton {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
background: #fff2d0;
|
||||
border-bottom: 3px solid var(--line);
|
||||
}
|
||||
|
||||
.tab { padding: 8px 5px; font-size: 13px; }
|
||||
|
||||
.drawerBody {
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
background:
|
||||
linear-gradient(45deg, rgba(255,255,255,.4) 25%, transparent 25%) 0 0/18px 18px,
|
||||
var(--panel-solid);
|
||||
}
|
||||
|
||||
.tabPanel { display: none; }
|
||||
.tabPanel.active { display: block; }
|
||||
|
||||
.compactNameField { margin-bottom: 5px; }
|
||||
.roleHintInline { margin: 0 0 10px; }
|
||||
|
||||
.card {
|
||||
background: #fffdf5;
|
||||
border: 2px solid var(--line);
|
||||
box-shadow: 4px 4px 0 rgba(36,48,68,.12);
|
||||
padding: 9px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stack > * + * { margin-top: 10px; }
|
||||
.cardTitle {
|
||||
font-weight: 950;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
font-size: 12px;
|
||||
color: #3c4b60;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 5px;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 0;
|
||||
background: #fffaf0;
|
||||
color: var(--ink);
|
||||
padding: 9px 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus { background: #fff; box-shadow: 0 0 0 3px rgba(119,201,255,.35); }
|
||||
textarea { resize: vertical; }
|
||||
input[type="color"] { height: 39px; padding: 3px; }
|
||||
input[type="checkbox"] { width: auto; margin: 0 8px 0 0; }
|
||||
|
||||
.twoCols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.segmented button { font-size: 12px; padding: 8px 5px; }
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.editorCard { padding-bottom: 10px; }
|
||||
.editorTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.sideSwitcher {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
.sideSwitcher button { padding: 6px 7px; font-size: 11px; }
|
||||
|
||||
.toolRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
|
||||
.editorToolRow { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.editorHistoryRow { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.editorMoveRow { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.editorFileRow { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.toolRow input { margin: 0; }
|
||||
.tool { padding: 7px 5px; font-size: 11px; }
|
||||
button:disabled, .tool:disabled { opacity: .45; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
|
||||
#paintCanvas {
|
||||
display: block;
|
||||
width: min(100%, 330px);
|
||||
margin: 0 auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
image-rendering: pixelated;
|
||||
border: 3px solid var(--line);
|
||||
background:
|
||||
linear-gradient(45deg, #f4ebdc 25%, transparent 25%) 0 0/16px 16px,
|
||||
linear-gradient(45deg, transparent 75%, #f4ebdc 75%) 0 0/16px 16px,
|
||||
#fffaf0;
|
||||
cursor: crosshair;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.compactSettings .twoCols { align-items: center; }
|
||||
.toggleLine {
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.behaviorNote {
|
||||
border: 2px dashed rgba(45,60,80,.22);
|
||||
padding: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.actionRow.wrap { flex-wrap: wrap; }
|
||||
.actionRow button { flex: 1; }
|
||||
.lineageNote { min-height: 18px; color: var(--muted); font-size: 12px; }
|
||||
|
||||
.assetList { display: grid; gap: 10px; }
|
||||
.assetCard {
|
||||
display: grid;
|
||||
grid-template-columns: 68px 1fr;
|
||||
gap: 10px;
|
||||
border: 2px solid var(--line);
|
||||
background: #fff9e8;
|
||||
padding: 8px;
|
||||
box-shadow: 3px 3px 0 rgba(36,48,68,.12);
|
||||
}
|
||||
.assetCard.selected { background: #e6ffef; }
|
||||
.assetPreview {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
image-rendering: pixelated;
|
||||
background: #fff3d9;
|
||||
border: 2px solid var(--line);
|
||||
}
|
||||
.assetMeta strong { display: block; font-size: 14px; }
|
||||
.assetMeta span { display: block; margin-top: 3px; color: var(--muted); font-size: 12px; line-height: 1.3; }
|
||||
.assetActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.assetActions button { padding: 5px 7px; font-size: 11px; }
|
||||
|
||||
.modeGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.modeGrid button { padding: 8px 5px; font-size: 12px; }
|
||||
|
||||
.legend {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.legend span { display: flex; align-items: center; gap: 6px; }
|
||||
.legend i {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--line);
|
||||
}
|
||||
.legend .water { background: #67b5d9; }
|
||||
.legend .sand { background: #ead493; }
|
||||
.legend .grass { background: #8bce76; }
|
||||
.legend .highland { background: #a7be80; }
|
||||
|
||||
#dataBox { min-height: 160px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; }
|
||||
|
||||
.toast {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
left: 50%;
|
||||
bottom: 22px;
|
||||
transform: translateX(-50%);
|
||||
background: #fffdf5;
|
||||
border: 3px solid var(--line);
|
||||
box-shadow: var(--hard-shadow);
|
||||
padding: 10px 14px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.topHud { align-items: flex-start; flex-direction: column; right: 70px; }
|
||||
.clockCard, .authorCard { width: 100%; }
|
||||
.toolHud { top: auto; bottom: 122px; right: 10px; }
|
||||
.infoHud { left: 10px; right: 10px; width: auto; }
|
||||
.studioDrawer { width: calc(100vw - 18px); }
|
||||
.toolRow { grid-template-columns: 44px repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
/* refinements */
|
||||
.toolHud {
|
||||
top: 142px;
|
||||
right: 18px;
|
||||
}
|
||||
.edgeAdd {
|
||||
top: calc(50% + 24px);
|
||||
width: 62px;
|
||||
height: 92px;
|
||||
background: linear-gradient(180deg, #ff97c2, #ff75aa);
|
||||
}
|
||||
.studioDrawer {
|
||||
width: min(520px, calc(100vw - 28px));
|
||||
}
|
||||
.drawerHead { background: #ffe1ef; }
|
||||
.drawerBody {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255,255,255,.42) 0 1px, transparent 1px) 0 0/20px 20px,
|
||||
linear-gradient(0deg, rgba(255,255,255,.42) 0 1px, transparent 1px) 0 0/20px 20px,
|
||||
#fff8e9;
|
||||
}
|
||||
.card {
|
||||
border-width: 3px;
|
||||
box-shadow: 5px 5px 0 rgba(36,48,68,.13);
|
||||
}
|
||||
.heroEditor {
|
||||
background: #fffdf7;
|
||||
border-color: #26364c;
|
||||
}
|
||||
#paintCanvas {
|
||||
margin-bottom: 10px;
|
||||
max-height: min(42vh, 330px);
|
||||
}
|
||||
.toolRow {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
margin-top: 0;
|
||||
}
|
||||
.paletteGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(13, 1fr);
|
||||
gap: 3px;
|
||||
margin: 6px 0 6px;
|
||||
padding: 6px;
|
||||
border: 2px solid rgba(36,48,68,.28);
|
||||
background: #fff6df;
|
||||
}
|
||||
.paletteSwatch {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
min-height: 18px;
|
||||
border: 2px solid rgba(36,48,68,.38);
|
||||
box-shadow: 2px 2px 0 rgba(36,48,68,.13);
|
||||
cursor: pointer;
|
||||
}
|
||||
.paletteSwatch.active {
|
||||
outline: 3px solid #ff7aad;
|
||||
outline-offset: 1px;
|
||||
border-color: #26364c;
|
||||
}
|
||||
.paletteSwatch::after {
|
||||
content: attr(data-code);
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
bottom: 0;
|
||||
color: rgba(20,26,38,.52);
|
||||
font-size: 7px;
|
||||
font-weight: 900;
|
||||
text-shadow: 0 1px 0 rgba(255,255,255,.7);
|
||||
}
|
||||
.quickSetupBar { margin-bottom: 6px; }
|
||||
.settingsCard .segmented { margin-top: 2px; }
|
||||
.markerRow {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.markerChip {
|
||||
border: 2px dashed rgba(36,48,68,.25);
|
||||
background: #fff6df;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.summonCard {
|
||||
background: linear-gradient(180deg, #fffdf5, #fff2d7);
|
||||
}
|
||||
.finishActions {
|
||||
display: grid;
|
||||
grid-template-columns: 1.35fr .8fr .6fr;
|
||||
}
|
||||
.bigPrimary {
|
||||
min-height: 48px;
|
||||
font-size: 14px;
|
||||
background: linear-gradient(180deg, #89e4ae, #65d596) !important;
|
||||
}
|
||||
.sideSwitcher button:first-child.active::after,
|
||||
.sideSwitcher button:nth-child(2).active::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
}
|
||||
.spawnPop {
|
||||
pointer-events: none;
|
||||
}
|
||||
.selectedAsset { font-weight: 900; padding-bottom: 8px; border-bottom: 2px dashed rgba(45,60,80,.25); }
|
||||
@media (max-width: 720px) {
|
||||
.toolHud { top: auto; bottom: 126px; right: 10px; }
|
||||
.finishActions { grid-template-columns: 1fr; }
|
||||
.paletteGrid { grid-template-columns: repeat(8, 1fr); }
|
||||
}
|
||||
|
||||
.paletteGrid button { width: 100%; aspect-ratio: 1 / 1; padding: 0; min-height: 22px; }
|
||||
.sideSwitcher button { white-space: nowrap; }
|
||||
.heroEditor .hint { line-height: 1.35; }
|
||||
|
||||
|
||||
.compactEditorTop { margin-bottom: 4px; min-height: 0; }
|
||||
body, button, input, select, textarea { font-smooth: never; -webkit-font-smoothing: none; }
|
||||
|
||||
/* latest layout fixes */
|
||||
.studioDrawer { width: min(600px, calc(100vw - 24px)); }
|
||||
.paintLayout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 360px) 1fr;
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
#paintCanvas {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
}
|
||||
.paletteGrid {
|
||||
margin: 0;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
align-content: start;
|
||||
gap: 4px;
|
||||
max-height: 360px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.paletteSwatch, .paletteGrid button {
|
||||
min-height: 20px;
|
||||
}
|
||||
.sideSwitcher { justify-content: flex-start; }
|
||||
.authorCard span { white-space: nowrap; }
|
||||
@media (max-width: 720px) {
|
||||
.paintLayout { grid-template-columns: 1fr; }
|
||||
.paletteGrid { grid-template-columns: repeat(10, 1fr); max-height: none; }
|
||||
}
|
||||
|
||||
/* Selection bubble + outline controls */
|
||||
.palettePanel { display: grid; gap: 8px; }
|
||||
.selectionBubble {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 9;
|
||||
min-width: 126px;
|
||||
max-width: 210px;
|
||||
padding: 8px;
|
||||
background: #fffdf5;
|
||||
border: 3px solid var(--line);
|
||||
box-shadow: 4px 4px 0 rgba(36,48,68,.20);
|
||||
pointer-events: auto;
|
||||
text-align: center;
|
||||
}
|
||||
.selectionBubble::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -10px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #fffdf5;
|
||||
border-right: 3px solid var(--line);
|
||||
border-bottom: 3px solid var(--line);
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
}
|
||||
.bubbleName {
|
||||
font-size: 12px;
|
||||
font-weight: 950;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
.bubbleAuthor {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
font-weight: 800;
|
||||
}
|
||||
.bubbleVotes {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.bubbleVotes button {
|
||||
padding: 3px 7px;
|
||||
min-width: 30px;
|
||||
font-size: 11px;
|
||||
}
|
||||
#voteScore {
|
||||
font-weight: 950;
|
||||
font-size: 12px;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.bubbleRemixFrom, .bubbleRemixCount {
|
||||
margin-top: 3px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.bubbleActions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.bubbleActions button {
|
||||
padding: 4px 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.bubbleVotes button.mutedVote, .assetActions button.mutedAction {
|
||||
opacity: .45;
|
||||
}
|
||||
.bubbleVotes button.activeVote {
|
||||
background: var(--sun);
|
||||
}
|
||||
|
||||
/* Pixel-ish UI refinements */
|
||||
body, button, input, select, textarea {
|
||||
font-family: "Courier New", "Lucida Console", Monaco, monospace;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
button, .tab, .tool, .iconButton, .cardTitle, .logo {
|
||||
text-transform: uppercase;
|
||||
font-weight: 900;
|
||||
}
|
||||
.libraryHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.hiddenAssetPanel {
|
||||
border: 2px dashed rgba(36,48,68,.35);
|
||||
background: #fff3d6;
|
||||
padding: 8px;
|
||||
}
|
||||
.compactAssetList .assetCard { opacity: .82; }
|
||||
.librarySectionTitle {
|
||||
margin: 12px 0 6px;
|
||||
padding: 5px 7px;
|
||||
background: #ffe6f0;
|
||||
border: 2px solid var(--line);
|
||||
box-shadow: 3px 3px 0 rgba(36,48,68,.12);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.advancedRow {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.advancedOnly.active { background: #bde9ff; }
|
||||
.depthLegend {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Simplified current UI */
|
||||
body, button, input, select, textarea {
|
||||
font-family: "MS Gothic", "Osaka-Mono", "DotumChe", "Courier New", ui-monospace, monospace;
|
||||
letter-spacing: .02em;
|
||||
-webkit-font-smoothing: none;
|
||||
text-rendering: geometricPrecision;
|
||||
}
|
||||
.toolRow { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
.editorHistoryRow { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.advancedRow {
|
||||
grid-template-columns: max-content max-content max-content 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
.librarySectionTitle {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
margin: 10px 0 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.librarySectionTitle::after {
|
||||
content: " click: genre";
|
||||
float: right;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.assetSectionGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.libraryEmptyNote {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
border: 2px dashed rgba(36,48,68,.2);
|
||||
padding: 8px;
|
||||
background: #fff9e8;
|
||||
}
|
||||
.assetSectionGrid .assetCard {
|
||||
grid-template-columns: 54px 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.assetSectionGrid .assetPreview {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
.assetSectionGrid .assetActions button { padding: 4px 5px; font-size: 10px; }
|
||||
@media (max-width: 720px) {
|
||||
.assetSectionGrid { grid-template-columns: 1fr; max-height: none; }
|
||||
.toolRow { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.editorHistoryRow { grid-template-columns: 1fr 1fr; }
|
||||
.advancedRow { grid-template-columns: 1fr 1fr 1fr; }
|
||||
}
|
||||
|
||||
|
||||
.syncStats {
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(20, 25, 32, 0.06);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue