From 94571943cf4f70cbabad1488d06559df80b166f8 Mon Sep 17 00:00:00 2001
From: 33333-33333
Date: Tue, 2 Jun 2026 17:19:36 +0900
Subject: [PATCH] split
---
.gitignore | 3 +
README.md | 12 +
README.md.bak | 58 -
app.js | 428 +-
app.js.bak | 3774 -----------------
index.html | 44 +-
index.html.bak | 203 -
js/core-utils.js | 33 +
js/lighting.js | 136 +-
js/module-loader.js | 22 +
js/phase2-sync.js | 4 +
js/phase2-sync.js.bak | 402 --
js/render-pipeline.js | 11 -
js/rotation-policy.js | 76 +
js/state-index.js | 103 +
js/vector.js | 24 -
.../compression_lab.cpython-313.pyc | Bin 2515 -> 0 bytes
.../rotation_worker.cpython-313.pyc | Bin 20663 -> 0 bytes
server/rotation_policy.json | 12 +
server/rotation_worker.py | 36 +-
server/test_rotation_worker.py | 64 +
styles.css | 34 +
styles.css.bak | 901 ----
23 files changed, 817 insertions(+), 5563 deletions(-)
create mode 100644 .gitignore
delete mode 100644 README.md.bak
delete mode 100644 app.js.bak
delete mode 100644 index.html.bak
create mode 100644 js/core-utils.js
create mode 100644 js/module-loader.js
delete mode 100644 js/phase2-sync.js.bak
delete mode 100644 js/render-pipeline.js
create mode 100644 js/rotation-policy.js
create mode 100644 js/state-index.js
delete mode 100644 js/vector.js
delete mode 100644 server/__pycache__/compression_lab.cpython-313.pyc
delete mode 100644 server/__pycache__/rotation_worker.cpython-313.pyc
create mode 100644 server/rotation_policy.json
create mode 100644 server/test_rotation_worker.py
delete mode 100644 styles.css.bak
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d52710d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+*.bak
+__pycache__/
+*.py[cod]
diff --git a/README.md b/README.md
index 7336ed8..209f736 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,14 @@ Browser-only prototype for a shared pixel-art island.
Open `index.html` in a modern browser. No build step or third-party dependency is required.
+## Refactor layout
+
+- `app.js` remains the browser bootstrap and main UI coordinator.
+- `js/core-utils.js`, `js/state-index.js`, `js/rotation-policy.js`, and `js/lighting.js` hold reusable logic shared by the app.
+- `js/editor-actions.js` is lazy-loaded when Pixel Studio opens; the app keeps inline fallbacks for first-use safety.
+- Server rotation defaults can be supplied with `server/rotation_policy.json` and `--config`.
+- Server policy tests live in `server/test_rotation_worker.py`.
+
## Current prototype scope
- Draw pixel assets in the in-browser editor.
@@ -269,3 +277,7 @@ Use this to compare minified JSON, gzip, zlib, and Brotli when available. Produc
- Replaced the numeric clock with a fixed analog day-night clock using a sky-color conic gradient and no numbers.
- Kept selected-object shadows mirrored from the ground line while the sprite jumps.
- Smoothed sky and overlay interpolation across dawn, day, evening, and night.
+
+## v16b hotfix
+- Fixed the dark/blank world regression caused by using `source-in` compositing directly on the main world canvas for ship water reflection.
+- Ship reflections are now precomposited on an offscreen canvas before drawing, so terrain and sprites are not erased or darkened.
diff --git a/README.md.bak b/README.md.bak
deleted file mode 100644
index 592176b..0000000
--- a/README.md.bak
+++ /dev/null
@@ -1,58 +0,0 @@
-# Pixel Island Summoner
-
-Browser-only prototype for a shared pixel-art island.
-
-## Run
-
-Open `index.html` in a modern browser. No build step or third-party dependency is required.
-
-## Current prototype scope
-
-- Draw pixel assets in the in-browser editor.
-- Save assets to the local library.
-- Place static assets and summon dynamic assets onto the island.
-- Dynamic assets default to right-facing on spawn; left-facing is rendered by mirroring during movement only.
-- Local voting, hiding, remix/copy edit, and import/export tools are included.
-
-## Phase 1 changes
-
-- Asset data is normalized to schema 2+ compatible shape.
-- Dynamic left-facing pixels are not stored; `faces.left` is represented as `mirror`.
-- Empty depth/light metadata is omitted or normalized.
-- Editor tools include Draw, Erase, Fill, Pick, Undo, and Redo.
-
-## Phase 2 changes
-
-- Local save uses a compact Phase 2 envelope in `localStorage`.
-- Pixel planes are bbox-cropped and RLE-compressed when this is smaller than raw cropped data.
-- Compact asset bundles can be exported separately from world snapshots.
-- World snapshots contain placements, dynamic summons, and an asset manifest rather than full asset blobs.
-- Local object/asset mutations append small sync events to `eventLog`.
-- IndexedDB asset/snapshot cache helpers are included for future server-backed missing-asset fetches.
-
-## Phase 3 changes
-
-- Terrain rendering is chunk-cached instead of using one large terrain canvas.
-- Only terrain chunks intersecting the current viewport are drawn.
-- Static and dynamic objects outside the viewport margin are culled before sprite generation/sorting.
-- Dynamic runtime updates are stepped at 15 Hz and capped per frame to avoid spiral-of-death on slow devices.
-- Rendering pauses while the browser tab is hidden and resumes on visibility change.
-- Tile/object lookups use a small spatial index for inspect, erase, and nearby-target searches.
-- Data tab reports visible terrain chunks for quick performance inspection.
-
-## Phase 4 changes
-
-- Editor schema is now 5.
-- Added Line and Rect tools. Rect supports filled rectangles with Shift; right-click erases line/rect cells.
-- Added Select tool. Drag to select a rectangular area, then drag the selection or use arrow keys/buttons to move it.
-- Nudge buttons move the active selection; without a selection they shift the whole drawing.
-- Added horizontal/vertical flip, palette-colored outline generation, and clear selection.
-- Added editor PNG export/import. PNG import quantizes the image to the current island palette and current canvas size.
-- Added keyboard shortcuts: B/E/F/I/L/R/S for tools, Ctrl/Cmd+Z/Y for history, arrows for moving selected pixels, Escape to clear selection.
-
-## Export formats
-
-- **Export full JSON**: human-readable full local state.
-- **Export compact**: compact local state suitable for saving/transferring.
-- **Export snapshot**: placement state plus asset manifest; requires asset bundles or cache to render fully.
-- **Export asset bundle**: compact asset blob list for missing-asset transfer.
diff --git a/app.js b/app.js
index 82ebed8..aa0b127 100644
--- a/app.js
+++ b/app.js
@@ -20,16 +20,22 @@
const VIEW_CULL_MARGIN = 192;
const DYNAMIC_LOGIC_STEP_MS = 1000 / 15;
const MAX_DYNAMIC_STEPS_PER_FRAME = 3;
+ const MAX_SPRITE_CACHE_ENTRIES = 260;
const COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const PALETTE = buildPalette();
const PALETTE_BY_CODE = Object.fromEntries(PALETTE.map((p) => [p.code, p.color]));
const MODULES = window.PixelIslandModules || {};
- const EditorActions = MODULES.EditorActions || null;
const Phase2Sync = MODULES.Phase2Sync || null;
+ const StateIndex = MODULES.StateIndex || null;
+ const RotationPolicy = MODULES.RotationPolicy || null;
+ const Lighting = MODULES.Lighting || null;
+ const ModuleLoader = MODULES.ModuleLoader || null;
const EDITOR_HISTORY_LIMIT = 80;
const PHASE5_GUARDRAILS = { maxAssets: 220, maxWorldObjects: 1000, maxReports: 200, defaultDisplayLimit: 250, newArrivalSlots: 150, revivalSlots: 100, publishLimitFirstDay: 5, publishLimitTrusted: 10, upvoteDelaySlots: 20, downvoteAdvanceSlots: 25, upvoteRankCap: 50, maxParticles: 200, particleMinZoom: 0.72 };
+ const editorActions = () => MODULES.EditorActions || null;
+
const $ = (id) => document.getElementById(id);
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const uid = () => Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-5);
@@ -143,6 +149,7 @@
let world = makeWorld();
let terrainCache = buildTerrainCache(world);
+ let terrainFrontCache = buildTerrainFrontCache(world);
let state = loadState();
let selectedAssetId = state.assets[0]?.id ?? null;
let mode = 'inspect';
@@ -154,6 +161,7 @@
let hoverTile = null;
let dynamicRuntime = [];
let spriteCache = new Map();
+ let lastRenderedSpriteInfo = new Map();
let lastRuntimeUpdate = performance.now();
let lastClockSecond = -1;
let toastTimer = null;
@@ -186,6 +194,7 @@
let coastalFoamTextures = null;
let mousePaint = { active: false, panning: false, lastX: 0, lastY: 0, button: 0 };
let shadowCanvasCache = new WeakMap();
+ let shipReflectionCanvasCache = new WeakMap();
let animationFrameId = 0;
let dynamicLogicRemainder = 0;
let worldIndex = makeEmptyWorldIndex();
@@ -210,6 +219,7 @@
function bootstrap() {
resizeCanvas();
resetView(false);
+ rebuildWorldIndex();
hydrateRuntime();
coastalFoamTextures = buildCoastalFoamTextures();
wireUI();
@@ -221,7 +231,6 @@
clearEditorHistory();
renderLibrary();
updateSelectedLabel();
- rebuildWorldIndex();
updateSyncStats();
updateGuardrailStats();
hydrateVisualSettingsUI();
@@ -478,12 +487,19 @@
function setDrawerOpen(open) {
els.drawer.classList.toggle('open', open);
els.openEditor.hidden = open;
+ if (open) ensureEditorHelpers();
if (!open) {
selectedObject = null;
updateSelectionBubble(performance.now());
}
}
+ function ensureEditorHelpers() {
+ if (editorActions() || !ModuleLoader?.loadScript) return;
+ ModuleLoader.loadScript('./js/editor-actions.js')
+ .catch((error) => console.warn('Editor helper lazy load failed; using inline fallbacks.', error));
+ }
+
function onDocumentContextMenu(event) {
if (!els.drawer?.classList.contains('open')) return;
if (els.drawer.contains(event.target) || els.openEditor?.contains(event.target) || els.openCreate?.contains(event.target) || els.openCollection?.contains(event.target) || els.openMenu?.contains(event.target)) return;
@@ -754,7 +770,7 @@
}
function makeEmptyWorldIndex() {
- return { placedByTile: new Map(), dynamicByHomeTile: new Map() };
+ return StateIndex?.build ? StateIndex.build({ assets: [], placed: [], dynamicSummons: [] }) : { assetById: new Map(), staticById: new Map(), dynamicById: new Map(), objectsById: new Map(), objectIdsByAssetId: new Map(), placedByTile: new Map(), dynamicByHomeTile: new Map() };
}
function pushIndexBucket(map, key, item) {
@@ -764,12 +780,25 @@
}
function rebuildWorldIndex() {
+ if (StateIndex?.build) {
+ worldIndex = StateIndex.build(state);
+ return;
+ }
const next = makeEmptyWorldIndex();
+ for (const asset of state.assets || []) {
+ if (asset?.id) next.assetById.set(asset.id, asset);
+ }
for (const placed of state.placed || []) {
+ next.staticById.set(placed.id, placed);
+ next.objectsById.set(placed.id, { kind: 'static', object: placed });
pushIndexBucket(next.placedByTile, tileKey(placed.x, placed.y), placed);
+ pushIndexBucket(next.objectIdsByAssetId, placed.assetId, placed.id);
}
for (const summon of state.dynamicSummons || []) {
+ next.dynamicById.set(summon.id, summon);
+ next.objectsById.set(summon.id, { kind: 'dynamic', object: summon });
pushIndexBucket(next.dynamicByHomeTile, tileKey(Math.round(summon.homeX), Math.round(summon.homeY)), summon);
+ pushIndexBucket(next.objectIdsByAssetId, summon.assetId, summon.id);
}
worldIndex = next;
}
@@ -789,12 +818,12 @@
}
function getObjectRecord(kind, objectId) {
- if (kind === 'static') return state.placed.find((p) => p.id === objectId) || null;
- return state.dynamicSummons.find((p) => p.id === objectId) || null;
+ if (kind === 'static') return worldIndex.staticById?.get(objectId) || state.placed.find((p) => p.id === objectId) || null;
+ return worldIndex.dynamicById?.get(objectId) || state.dynamicSummons.find((p) => p.id === objectId) || null;
}
function getObjectPublicAt(kind, object) {
- return Number(object?.publishedAt || object?.placedAt || object?.createdAt || Date.now());
+ return RotationPolicy?.objectPublicAt ? RotationPolicy.objectPublicAt(object) : Number(object?.publishedAt || object?.placedAt || object?.createdAt || Date.now());
}
function isPermanentlyHiddenObject(object) {
@@ -804,8 +833,12 @@
function isModeratedAssetHidden(asset) {
if (!asset) return false;
if (asset.status === 'permanent_hidden' || asset.status === 'violation_hidden') return true;
- const allObjects = [...(state.placed || []), ...(state.dynamicSummons || [])];
- return allObjects.some((object) => object.assetId === asset.id && isPermanentlyHiddenObject(object) && object.hiddenReason === 'moderation_violation');
+ const objectIds = worldIndex.objectIdsByAssetId?.get(asset.id) || [];
+ for (const objectId of objectIds) {
+ const entry = worldIndex.objectsById?.get(objectId);
+ if (entry?.object && isPermanentlyHiddenObject(entry.object) && entry.object.hiddenReason === 'moderation_violation') return true;
+ }
+ return false;
}
function isServerSuppressedObject(object) {
@@ -813,25 +846,12 @@
}
function getObjectRotationEntry(kind, object, baseIndex = 0) {
- const objectId = object?.id;
- const votes = getObjectVoteCounts(objectId);
+ const votes = getObjectVoteCounts(object?.id);
+ if (RotationPolicy?.entry) return RotationPolicy.entry(kind, object, baseIndex, votes, PHASE5_GUARDRAILS);
const rawUp = Number(votes.up) || 0;
const up = Math.min(rawUp, PHASE5_GUARDRAILS.upvoteRankCap);
const down = Number(votes.down) || 0;
- const delay = up * PHASE5_GUARDRAILS.upvoteDelaySlots;
- const advance = down * PHASE5_GUARDRAILS.downvoteAdvanceSlots;
- return {
- kind,
- object,
- id: objectId,
- assetId: object?.assetId,
- publicAt: getObjectPublicAt(kind, object),
- baseIndex,
- up,
- rawUp,
- down,
- effectiveSlot: baseIndex + delay - advance
- };
+ return { kind, object, id: object?.id, assetId: object?.assetId, publicAt: getObjectPublicAt(kind, object), baseIndex, up, rawUp, down, effectiveSlot: baseIndex + up * PHASE5_GUARDRAILS.upvoteDelaySlots - down * PHASE5_GUARDRAILS.downvoteAdvanceSlots };
}
function getRotationEntries(includeLocalHidden = false) {
@@ -853,6 +873,7 @@
}
function seededScore(id, salt = '') {
+ if (RotationPolicy?.seededScore) return RotationPolicy.seededScore(id, salt, state.lastRotationAt || Date.now());
const hash = fnv1a(`${id}|${salt}|${Math.floor((state.lastRotationAt || Date.now()) / (24 * 60 * 60 * 1000))}`);
return parseInt(hash.slice(0, 8), 16) / 0xffffffff;
}
@@ -860,6 +881,7 @@
function getIslandDisplayBuckets() {
const entries = getRotationEntries(false);
const localLimit = getLocalDisplayLimit();
+ if (RotationPolicy?.buckets) return RotationPolicy.buckets(entries, localLimit, PHASE5_GUARDRAILS, state.lastRotationAt || Date.now());
const newCap = Math.min(PHASE5_GUARDRAILS.newArrivalSlots, localLimit);
const revivalCap = Math.max(0, Math.min(PHASE5_GUARDRAILS.revivalSlots, localLimit - newCap));
const newest = entries
@@ -904,6 +926,7 @@
function currentPublishLimit(now = Date.now()) {
if (!state.account?.createdAt) return 0;
+ if (RotationPolicy?.publishLimit) return RotationPolicy.publishLimit(state.account, now, PHASE5_GUARDRAILS);
return getAccountAgeMs(now) < 24 * 60 * 60 * 1000
? PHASE5_GUARDRAILS.publishLimitFirstDay
: PHASE5_GUARDRAILS.publishLimitTrusted;
@@ -1473,8 +1496,9 @@
if (paintTool === 'fill') {
const displayPixels = getDisplayEditorPixels();
const fillColor = shiftKey ? null : selectedColorCode;
- const nextDisplay = EditorActions?.floodFill
- ? EditorActions.floodFill(displayPixels, editorSize, x, y, fillColor)
+ const actions = editorActions();
+ const nextDisplay = actions?.floodFill
+ ? actions.floodFill(displayPixels, editorSize, x, y, fillColor)
: fallbackFloodFill(displayPixels, editorSize, x, y, fillColor);
if (nextDisplay.changed) {
editorPixels = nextDisplay.pixels;
@@ -2560,6 +2584,7 @@
editParentId = null;
editOriginalId = null;
editingAssetId = null;
+ rebuildWorldIndex();
saveState();
spriteCache.clear();
hydrateRuntime();
@@ -2818,7 +2843,8 @@
function makeAssetCard(asset) {
const card = document.createElement('article');
- card.className = `assetCard${asset.id === selectedAssetId ? ' selected' : ''}`;
+ const expanded = asset.id === selectedAssetId;
+ card.className = `assetCard${expanded ? ' selected expanded' : ''}`;
const preview = document.createElement('canvas');
preview.className = 'assetPreview';
@@ -2828,53 +2854,60 @@
const meta = document.createElement('div');
meta.className = 'assetMeta';
- const lineage = asset.parentAssetId ? ' / derivative' : '';
- meta.innerHTML = `${displayCategory(asset)} / ${cap(asset.subtype)} / ${asset.size}×${asset.size}${lineage}`;
- meta.querySelector('strong').textContent = asset.name;
- meta.querySelectorAll('span')[1].textContent = `Author: ${asset.author || 'Local Artist'} · Remixed: ${getRemixCount(asset.id)}`;
+ const title = document.createElement('strong');
+ title.textContent = asset.name;
+ meta.append(title);
card.addEventListener('click', () => {
- selectedAssetId = asset.id;
+ selectedAssetId = expanded ? null : asset.id;
updateSelectedLabel();
renderLibrary();
- focusAssetInWorld(asset);
+ if (selectedAssetId) focusAssetInWorld(asset);
});
- const actions = document.createElement('div');
- actions.className = 'assetActions';
- const assetVotes = getAssetVoteCounts(asset.id);
- const assetPreviousVote = assetVotes.voters?.[currentVoterKey()] || 0;
- const up = makeButton(`▲ ${assetVotes.up}`, (event) => { event.stopPropagation(); voteAsset(asset.id, 1); });
- const down = makeButton(`▼ ${assetVotes.down}`, (event) => { event.stopPropagation(); voteAsset(asset.id, -1); });
- up.classList.toggle('activeVote', assetPreviousVote > 0);
- down.classList.toggle('activeVote', assetPreviousVote < 0);
- up.classList.toggle('mutedVote', assetPreviousVote < 0);
- down.classList.toggle('mutedVote', assetPreviousVote > 0);
- const hide = makeButton('Hide', (event) => { event.stopPropagation(); hideAsset(asset.id); });
- hide.classList.toggle('mutedAction', assetPreviousVote >= 0);
- const isMine = (asset.author || 'Local Artist') === (state.authorName || 'Local Artist');
- const remix = makeButton('Remix', (event) => {
- event?.stopPropagation?.();
- remixEdit(asset);
- });
- const move = isMine ? makeButton('Place/Move', (event) => {
- event?.stopPropagation?.();
- selectedAssetId = asset.id;
- updateSelectedLabel();
- renderLibrary();
- setMode('place');
- setDrawerOpen(false);
- toast('Click a valid tile to place or move it.');
- }) : null;
- const del = makeButton('Delete', (event) => {
- event?.stopPropagation?.();
- deleteAsset(asset);
- });
- del.classList.add('danger');
- actions.append(up, down, hide, remix);
- if (move) actions.append(move);
- if (isMine) actions.append(del);
- meta.append(actions);
+ if (expanded) {
+ const lineage = asset.parentAssetId ? ' / derivative' : '';
+ const span1 = document.createElement('span');
+ span1.textContent = `${displayCategory(asset)} / ${cap(asset.subtype)} / ${asset.size}×${asset.size}${lineage}`;
+ const span2 = document.createElement('span');
+ span2.textContent = `Author: ${asset.author || 'Local Artist'} · Remixed: ${getRemixCount(asset.id)}`;
+ meta.append(span1, span2);
+
+ const actions = document.createElement('div');
+ actions.className = 'assetActions';
+ const assetVotes = getAssetVoteCounts(asset.id);
+ const assetPreviousVote = assetVotes.voters?.[currentVoterKey()] || 0;
+ const up = makeButton(`▲ ${assetVotes.up}`, (event) => { event.stopPropagation(); voteAsset(asset.id, 1); });
+ const down = makeButton(`▼ ${assetVotes.down}`, (event) => { event.stopPropagation(); voteAsset(asset.id, -1); });
+ up.classList.toggle('activeVote', assetPreviousVote > 0);
+ down.classList.toggle('activeVote', assetPreviousVote < 0);
+ up.classList.toggle('mutedVote', assetPreviousVote < 0);
+ down.classList.toggle('mutedVote', assetPreviousVote > 0);
+ const hide = makeButton('Hide', (event) => { event.stopPropagation(); hideAsset(asset.id); });
+ hide.classList.toggle('mutedAction', assetPreviousVote >= 0);
+ const isMine = (asset.author || 'Local Artist') === (state.authorName || 'Local Artist');
+ const remix = makeButton('Remix', (event) => { event?.stopPropagation?.(); remixEdit(asset); });
+ const move = isMine ? makeButton('Place/Move', (event) => {
+ event?.stopPropagation?.();
+ selectedAssetId = asset.id;
+ updateSelectedLabel();
+ renderLibrary();
+ setMode('place');
+ setDrawerOpen(false);
+ toast('Click a valid tile to place or move it.');
+ }) : null;
+ const del = makeButton('Delete', (event) => { event?.stopPropagation?.(); deleteAsset(asset); });
+ del.classList.add('danger');
+ actions.append(up, down, hide, remix);
+ if (move) actions.append(move);
+ if (isMine) actions.append(del);
+ meta.append(actions);
+ } else {
+ const mini = document.createElement('span');
+ mini.textContent = `${cap(asset.subtype)} · ${asset.size}×${asset.size}`;
+ meta.append(mini);
+ }
+
card.append(preview, meta);
return card;
}
@@ -3381,6 +3414,7 @@
}
function getShadowForMinute(minute) {
+ if (Lighting?.getShadowForMinute) return Lighting.getShadowForMinute(minute);
const m = clamp(Number(minute) || 0, 0, 10);
const smooth = (t) => clamp(t, 0, 1) * clamp(t, 0, 1) * (3 - 2 * clamp(t, 0, 1));
let dirX = -0.9;
@@ -3449,6 +3483,7 @@
}
function getPhase() {
+ if (Lighting?.getPhase) return Lighting.getPhase({ dayMs: DAY_MS, now: Date.now(), dayNightEnabled: visualSettings().enableDayNight !== false, mixHex });
if (visualSettings().enableDayNight === false) {
return { key: 'day', label: 'Day', progress: 0.25, sky: '#86d5ff', darkness: 0, darkOverlay: 'rgba(12, 19, 45, 0)', tint: 'rgba(255,255,255,0)', tintAlpha: 0, shadow: getShadowForMinute(3) };
}
@@ -3544,6 +3579,7 @@
ctx.imageSmoothingEnabled = false;
drawTerrainCache(terrainCache);
drawHoverTile();
+ lastRenderedSpriteInfo.clear();
const lightSources = [];
const visibleItems = drawObjects(time, lightSources, phase);
if (visualSettings().enableParticles) {
@@ -3554,6 +3590,7 @@
drawSpawnEffects(time);
drawConfettiParticles(time);
}
+ drawTerrainFrontCache(terrainFrontCache);
const nightLightsActive = areNightLightsActive(phase);
ctx.restore();
@@ -3632,10 +3669,23 @@
const applyTileLift = !(asset.category === 'dynamic' && asset.subtype === 'bird');
if (applyTileLift) pos.y -= getLiftAtCoord(item.x, item.y);
let bob = 0;
+ let stretchX = 1;
+ let stretchY = 1;
let alpha = 1;
let side = 'right';
let angle = 0;
- if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship')) bob += Math.sin(time / 900 + item.x * .7) * 1.6;
+
+ if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship')) {
+ bob += Math.sin(time / 900 + item.x * .7) * 1.6;
+ if (asset.subtype === 'ship') {
+ const drift = Math.sin(time / 2400 + item.x * 0.37 + item.y * 0.19) * 5.2;
+ const glide = Math.cos(time / 3200 + item.x * 0.21) * 2.6;
+ pos.x += drift;
+ pos.y += glide * 0.22;
+ angle += Math.sin(time / 1400 + item.x * 0.2) * 0.035;
+ }
+ }
+
if (asset.category === 'dynamic') {
const runtime = item.source || {};
const seed = Number.isFinite(runtime.seed) ? runtime.seed : 0;
@@ -3653,32 +3703,41 @@
bob = -hop * (2.2 + terrainBoost * 1.8 + (liftNow > 0 ? 0.6 : 0));
const cycle = Math.floor(jumpPhase / (Math.PI * 0.9));
const rand = pseudoNoise(cycle + seed * 13.17);
- angle = (rand - 0.5) * 0.24 * hop;
+ angle += (rand - 0.5) * 0.24 * hop;
+ const land = Math.pow(Math.max(0, Math.cos(jumpPhase * 2)), 10) * Math.max(0, 1 - hop);
+ stretchX += land * 0.18;
+ stretchY -= land * 0.12;
}
}
if (asset.subtype === 'bird') bob = -10 - Math.sin(time / 250 + seed) * 4.5;
if (asset.subtype === 'fish') { alpha = .42; bob = 4 + Math.sin(time / 380 + seed) * 1.7; }
}
+
if (includeSelectBounce && selectedObject?.id === item.source?.id) {
const selectedT = clamp((time - (selectedObject.selectedAt || 0)) / 560, 0, 1);
if (selectedT < 1) {
const bounce = Math.sin(selectedT * Math.PI);
- bob += -bounce * 8;
+ bob += -bounce * (asset.category === 'dynamic' ? 9 : 8);
const tiltSeed = parseInt(fnv1a(`${item.source?.id || asset.id}:select`).slice(0, 6), 16) || 1;
- const tiltSign = pseudoNoise(tiltSeed) > 0.5 ? 1 : -1;
- angle += tiltSign * bounce * 0.18;
+ const tiltRand = pseudoNoise(tiltSeed * 0.013) - 0.5;
+ angle += tiltRand * 0.32 * bounce;
+ const land = Math.exp(-Math.pow((selectedT - 0.92) / 0.09, 2));
+ stretchX += land * 0.26;
+ stretchY -= land * 0.18;
}
}
+
const sprite = getSpriteCanvas(asset, side);
const drawX = pos.x - sprite.width / 2;
const anchorY = asset.category === 'static' ? 0 : TILE_H / 2;
const drawY = pos.y + anchorY - sprite.height + bob;
- return { asset, pos, sprite, drawX, drawY, alpha, angle, side, bob, anchorY };
+ return { asset, pos, sprite, drawX, drawY, alpha, angle, side, bob, anchorY, stretchX, stretchY };
}
function drawSpriteItem(item, time, lightSources, underwater, phase) {
const info = getSpriteDrawInfo(item, time, true);
- const { asset, pos, sprite, drawX, drawY, alpha, angle } = info;
+ const { asset, pos, sprite, drawX, drawY, alpha, angle, stretchX = 1, stretchY = 1 } = info;
+ lastRenderedSpriteInfo.set(item.source?.id || asset.id, { ...info, time });
if (visualSettings().enableParticles && asset.category === 'static' && asset.subtype === 'ship') drawShipRipples(pos, time, item.source?.id || asset.id);
@@ -3686,16 +3745,19 @@
ctx.save();
ctx.globalAlpha = alpha;
- if (angle) {
- ctx.translate(Math.round(drawX + sprite.width / 2), Math.round(drawY + sprite.height * 0.8));
- ctx.rotate(angle);
+ const pivotX = Math.round(drawX + sprite.width / 2);
+ const pivotY = Math.round(drawY + sprite.height * 0.8);
+ if (angle || Math.abs(stretchX - 1) > 0.001 || Math.abs(stretchY - 1) > 0.001) {
+ ctx.translate(pivotX, pivotY);
+ if (angle) ctx.rotate(angle);
+ if (Math.abs(stretchX - 1) > 0.001 || Math.abs(stretchY - 1) > 0.001) ctx.scale(stretchX, stretchY);
ctx.drawImage(sprite, Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8));
} else {
ctx.drawImage(sprite, Math.round(drawX), Math.round(drawY));
}
if (underwater) {
ctx.fillStyle = 'rgba(103, 181, 217, .18)';
- if (angle) ctx.fillRect(Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8), sprite.width, sprite.height);
+ if (angle || Math.abs(stretchX - 1) > 0.001 || Math.abs(stretchY - 1) > 0.001) ctx.fillRect(Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8), sprite.width, sprite.height);
else ctx.fillRect(Math.round(drawX), Math.round(drawY), sprite.width, sprite.height);
}
ctx.restore();
@@ -3920,22 +3982,30 @@
function drawSpriteShadow(info, phase) {
- const { drawX, sprite, pos, anchorY, bob } = info;
+ const { drawX, sprite, pos, anchorY, bob, stretchX = 1, stretchY = 1, asset } = info;
const shadow = phase?.shadow || { dirX: 0, length: 1, skewX: 0, scaleY: 0.28, alpha: .16 };
if ((shadow.alpha || 0) <= 0.001) return;
const silhouette = getShadowCanvas(sprite);
const contactX = drawX + sprite.width / 2;
- // Keep the mirror line on the ground. When a selected sprite jumps upward,
- // the reflected silhouette moves downward by the same amount instead of floating.
const groundY = pos.y + anchorY;
const mirrorOffset = Math.max(0, -(bob || 0));
ctx.save();
ctx.globalAlpha = shadow.alpha;
ctx.translate(contactX, groundY);
- ctx.transform(1 + shadow.length * 0.08, 0, shadow.skewX, shadow.scaleY, 0, 0);
+ ctx.transform((1 + shadow.length * 0.08) * stretchX, 0, shadow.skewX, shadow.scaleY * Math.max(0.88, stretchY), 0, 0);
ctx.drawImage(silhouette, Math.round(-sprite.width / 2), Math.round(mirrorOffset));
ctx.restore();
+
+ if (asset?.subtype === 'ship') {
+ const reflection = getShipReflectionCanvas(sprite);
+ ctx.save();
+ ctx.globalAlpha = 0.24;
+ ctx.translate(contactX, groundY + 1);
+ ctx.transform(1.08 * stretchX, 0, shadow.skewX * 0.55, 0.36 * Math.max(0.92, stretchY), 0, 0);
+ ctx.drawImage(reflection, Math.round(-sprite.width / 2), Math.round(mirrorOffset + 2));
+ ctx.restore();
+ }
}
function getShadowCanvas(sprite) {
@@ -3957,6 +4027,29 @@
return canvas;
}
+ function getShipReflectionCanvas(sprite) {
+ if (shipReflectionCanvasCache.has(sprite)) return shipReflectionCanvasCache.get(sprite);
+ const canvas = document.createElement('canvas');
+ canvas.width = sprite.width;
+ canvas.height = sprite.height;
+ const c = canvas.getContext('2d');
+ c.imageSmoothingEnabled = false;
+ c.save();
+ c.translate(0, sprite.height);
+ c.scale(1, -1);
+ c.drawImage(sprite, 0, 0);
+ c.restore();
+ c.globalCompositeOperation = 'source-in';
+ const refl = c.createLinearGradient(0, 0, 0, canvas.height);
+ refl.addColorStop(0, 'rgba(174,230,255,0.46)');
+ refl.addColorStop(0.55, 'rgba(92,172,214,0.20)');
+ refl.addColorStop(1, 'rgba(54,120,170,0.02)');
+ c.fillStyle = refl;
+ c.fillRect(0, 0, canvas.width, canvas.height);
+ shipReflectionCanvasCache.set(sprite, canvas);
+ return canvas;
+ }
+
function currentVoterKey() {
return String(state.account?.id || state.authorName || els.authorName?.value || 'Local Artist').trim().toLowerCase();
@@ -4080,16 +4173,17 @@
}
function getSelectedBubbleAnchor(time = performance.now()) {
+ if (!selectedObject) return null;
+ const cached = lastRenderedSpriteInfo.get(selectedObject.id);
+ if (cached) {
+ const topPad = Math.max(18, Math.min(34, cached.sprite.height * 0.22));
+ return { x: cached.drawX + cached.sprite.width / 2, y: cached.drawY - topPad, asset: cached.asset, objectId: selectedObject.id };
+ }
const item = getSelectedDrawableItem(time);
if (!item) return null;
const info = getSpriteDrawInfo(item, time, true);
- const topPad = Math.max(14, Math.min(30, info.sprite.height * 0.18));
- return {
- x: info.drawX + info.sprite.width / 2,
- y: info.drawY - topPad,
- asset: item.asset,
- objectId: item.source?.id || selectedObject.id
- };
+ const topPad = Math.max(18, Math.min(34, info.sprite.height * 0.22));
+ return { x: info.drawX + info.sprite.width / 2, y: info.drawY - topPad, asset: item.asset, objectId: item.source?.id || selectedObject.id };
}
function updateSelectionBubble(time) {
@@ -4110,8 +4204,8 @@
return;
}
els.selectionBubble.hidden = false;
- const bubbleX = clamp(sx, 96, cw - 96);
- const bubbleY = clamp(sy, 28, ch - 118);
+ const bubbleX = clamp(sx, 112, cw - 112);
+ const bubbleY = clamp(sy, 96, ch - 160);
els.selectionBubble.style.left = `${Math.round(bubbleX)}px`;
els.selectionBubble.style.top = `${Math.round(bubbleY)}px`;
els.selectionBubble.style.transform = 'translate(-50%, -100%)';
@@ -4383,8 +4477,14 @@
function getSpriteCanvas(asset, side) {
const lightsOn = areNightLightsActive(renderPhase);
const phaseKey = renderPhase ? `${renderPhase.key}:${Math.round((renderPhase.darkness || 0) * 10)}:${lightsOn ? 'lit' : 'unlit'}` : 'day:unlit';
- const key = `${asset.id}:${side}:${asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE}:${phaseKey}`;
- if (spriteCache.has(key)) return spriteCache.get(key);
+ const revision = asset.contentHash || asset.updatedAt || asset.createdAt || asset.pixels || asset.faces?.right || '';
+ const key = `${asset.id}:${revision}:${side}:${asset.size}:${asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE}:${phaseKey}`;
+ if (spriteCache.has(key)) {
+ const cached = spriteCache.get(key);
+ spriteCache.delete(key);
+ spriteCache.set(key, cached);
+ return cached;
+ }
const pixels = getAssetPixels(asset, side);
const rawDepths = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size || 1);
const depths = asset.category === 'dynamic' && side === 'left' ? mirrorDepthPixels(rawDepths, asset.size) : rawDepths;
@@ -4404,10 +4504,18 @@
c.fillRect(x * scale, y * scale, scale, scale);
}
}
- spriteCache.set(key, canvas);
+ rememberSpriteCanvas(key, canvas);
return canvas;
}
+ function rememberSpriteCanvas(key, canvas) {
+ spriteCache.set(key, canvas);
+ while (spriteCache.size > MAX_SPRITE_CACHE_ENTRIES) {
+ const oldest = spriteCache.keys().next().value;
+ spriteCache.delete(oldest);
+ }
+ }
+
function getAssetPixels(asset, side = 'right') {
const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(asset.size), asset.size);
if (asset.category === 'dynamic' && side === 'left') return mirrorPixels(right, asset.size);
@@ -4534,6 +4642,49 @@
}
}
+ function buildTerrainFrontCache(worldData) {
+ const chunkSize = TERRAIN_CHUNK_SIZE;
+ const chunks = [];
+ for (let cy = 0; cy < WORLD_H; cy += chunkSize) {
+ for (let cx = 0; cx < WORLD_W; cx += chunkSize) {
+ const tiles = [];
+ for (let y = cy; y < Math.min(WORLD_H, cy + chunkSize); y++) {
+ for (let x = cx; x < Math.min(WORLD_W, cx + chunkSize); x++) {
+ const tile = worldData.get(x, y);
+ if (!tile || tile.type !== 'highland') continue;
+ const frontLeft = worldData.get(tile.x, tile.y + 1);
+ const frontRight = worldData.get(tile.x + 1, tile.y);
+ const leftLower = !frontLeft || frontLeft.type !== 'highland';
+ const rightLower = !frontRight || frontRight.type !== 'highland';
+ if (leftLower || rightLower) tiles.push(tile);
+ }
+ }
+ if (!tiles.length) continue;
+ const bounds = getTerrainChunkBounds(tiles);
+ const canvas = document.createElement('canvas');
+ canvas.width = Math.ceil(bounds.w);
+ canvas.height = Math.ceil(bounds.h);
+ const c = canvas.getContext('2d', { alpha: true });
+ c.imageSmoothingEnabled = false;
+ c.save();
+ c.translate(-bounds.x, -bounds.y);
+ for (const tile of tiles) drawTerrainFrontTile(c, tile, worldData);
+ c.restore();
+ chunks.push({ canvas, x: bounds.x, y: bounds.y, w: canvas.width, h: canvas.height });
+ }
+ }
+ return { chunks };
+ }
+
+ function drawTerrainFrontCache(cache) {
+ if (!cache?.chunks?.length) return;
+ const rect = getViewportWorldRect(64);
+ for (const chunk of cache.chunks) {
+ if (chunk.x + chunk.w < rect.left || chunk.x > rect.right || chunk.y + chunk.h < rect.top || chunk.y > rect.bottom) continue;
+ ctx.drawImage(chunk.canvas, chunk.x, chunk.y);
+ }
+ }
+
function buildCoastalFoamTextures() {
return { a: makeFoamTexture(24, 1), b: makeFoamTexture(24, 2) };
@@ -4591,6 +4742,35 @@
}
+ function drawTerrainFrontTile(c, tile, worldData) {
+ const { x, y } = tileToWorld(tile.x, tile.y);
+ const lift = getTileLift(tile);
+ const frontLeft = worldData.get(tile.x, tile.y + 1);
+ const frontRight = worldData.get(tile.x + 1, tile.y);
+ const leftLower = !frontLeft || frontLeft.type !== 'highland';
+ const rightLower = !frontRight || frontRight.type !== 'highland';
+ if (rightLower) {
+ c.fillStyle = '#7b8e6c';
+ c.beginPath();
+ c.moveTo(x, y + TILE_H - lift);
+ c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift);
+ c.lineTo(x + TILE_W / 2, y + TILE_H / 2);
+ c.lineTo(x, y + TILE_H);
+ c.closePath();
+ c.fill();
+ }
+ if (leftLower) {
+ c.fillStyle = '#6f8263';
+ c.beginPath();
+ c.moveTo(x - TILE_W / 2, y + TILE_H / 2 - lift);
+ c.lineTo(x, y + TILE_H - lift);
+ c.lineTo(x, y + TILE_H);
+ c.lineTo(x - TILE_W / 2, y + TILE_H / 2);
+ c.closePath();
+ c.fill();
+ }
+ }
+
function drawTerrainTile(c, tile, worldData) {
const { x, y } = tileToWorld(tile.x, tile.y);
const palette = {
@@ -4603,33 +4783,6 @@
const fill = tile.shade > 0 ? base : alt;
const lift = getTileLift(tile);
- if (tile.type === 'highland') {
- const frontLeft = worldData.get(tile.x, tile.y + 1);
- const frontRight = worldData.get(tile.x + 1, tile.y);
- const leftLower = !frontLeft || frontLeft.type !== 'highland';
- const rightLower = !frontRight || frontRight.type !== 'highland';
- if (rightLower) {
- c.fillStyle = '#7b8e6c';
- c.beginPath();
- c.moveTo(x, y + TILE_H - lift);
- c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift);
- c.lineTo(x + TILE_W / 2, y + TILE_H / 2);
- c.lineTo(x, y + TILE_H);
- c.closePath();
- c.fill();
- }
- if (leftLower) {
- c.fillStyle = '#6f8263';
- c.beginPath();
- c.moveTo(x - TILE_W / 2, y + TILE_H / 2 - lift);
- c.lineTo(x, y + TILE_H - lift);
- c.lineTo(x, y + TILE_H);
- c.lineTo(x - TILE_W / 2, y + TILE_H / 2);
- c.closePath();
- c.fill();
- }
- }
-
c.fillStyle = fill;
c.beginPath();
c.moveTo(x, y - lift);
@@ -4710,6 +4863,7 @@
function saveState() {
state.schema = SAVE_SCHEMA;
+ canonicalizeAssetStorage();
state.guardrails = { ...PHASE5_GUARDRAILS, ...(state.guardrails || {}) };
state.moderationReports = normalizeModerationReports(state.moderationReports || []);
state.settings = { ...defaultVisualSettings(), ...(state.settings || {}) };
@@ -4743,6 +4897,18 @@
};
}
+ function canonicalizeAssetStorage() {
+ let changed = false;
+ state.assets = (state.assets || []).map((asset) => {
+ const right = asset?.faces?.right || asset?.pixels;
+ const needsCanonical = Array.isArray(right) || Array.isArray(asset?.pixels) || Array.isArray(asset?.meta?.depthPixels);
+ if (!needsCanonical) return asset;
+ changed = true;
+ return normalizeAsset(asset);
+ });
+ if (changed) rebuildWorldIndex();
+ }
+
function normalizeAsset(asset) {
const size = Number(asset.size) || 16;
const category = asset.category === 'dynamic' ? 'dynamic' : 'static';
@@ -4998,6 +5164,7 @@
const byId = new Map(state.assets.map((asset) => [asset.id, asset]));
importedAssets.forEach((asset) => byId.set(asset.id, asset));
state.assets = [...byId.values()];
+ rebuildWorldIndex();
saveState();
spriteCache.clear();
renderLibrary();
@@ -5046,6 +5213,20 @@
state.sync.lastEventId = event.id;
}
+ function applySyncEvent(event) {
+ if (!event) return false;
+ const unpackObject = (kind, object) => kind === 'dynamic'
+ ? Phase2Sync?.unpackDynamic?.(object) || object
+ : Phase2Sync?.unpackPlacement?.(object) || object;
+ if (StateIndex?.reduce) StateIndex.reduce(state, event, { unpackAsset: Phase2Sync?.unpackAsset, unpackObject });
+ else Phase2Sync?.applyEvent?.(state, event);
+ rebuildWorldIndex();
+ spriteCache.clear();
+ state.sync ||= { lastEventId: null };
+ state.sync.lastEventId = event.id || state.sync.lastEventId;
+ return true;
+ }
+
function cachePhase2State() {
if (!Phase2Sync?.cacheAssets) return;
Phase2Sync.cacheAssets(state.assets || []).catch((error) => console.warn('Phase 2 asset cache failed.', error));
@@ -5211,7 +5392,7 @@
}
function findAsset(id) {
- return state.assets.find((asset) => asset.id === id) || null;
+ return worldIndex.assetById?.get(id) || state.assets.find((asset) => asset.id === id) || null;
}
function blankPixels(size) {
@@ -5727,5 +5908,6 @@
}
+ window.PixelIslandDebug = { ...(window.PixelIslandDebug || {}), applySyncEvent };
bootstrap();
})();
diff --git a/app.js.bak b/app.js.bak
deleted file mode 100644
index f1b742c..0000000
--- a/app.js.bak
+++ /dev/null
@@ -1,3774 +0,0 @@
-(() => {
- 'use strict';
-
- console.info('Pixel Island Summoner loaded');
-
- const STORAGE_KEY = 'pixel-island-summoner:reset';
- const SAVE_SCHEMA = 5;
- const WORLD_W = 144;
- const WORLD_H = 112;
- const TILE_W = 32;
- const TILE_H = 16;
- const ORIGIN_X = WORLD_H * TILE_W / 2 + 80;
- const ORIGIN_Y = 42;
- const DAY_MS = 10 * 60 * 1000;
- const STATIC_SCALE = 2;
- const DYNAMIC_SCALE = 1;
- const MAX_ZOOM = 3.2;
- const MIN_ZOOM = 0.45;
- const TERRAIN_CHUNK_SIZE = 16;
- const VIEW_CULL_MARGIN = 192;
- const DYNAMIC_LOGIC_STEP_MS = 1000 / 15;
- const MAX_DYNAMIC_STEPS_PER_FRAME = 3;
-
- const COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
- const PALETTE = buildPalette();
- const PALETTE_BY_CODE = Object.fromEntries(PALETTE.map((p) => [p.code, p.color]));
- const MODULES = window.PixelIslandModules || {};
- const EditorActions = MODULES.EditorActions || null;
- const Phase2Sync = MODULES.Phase2Sync || null;
- const EDITOR_HISTORY_LIMIT = 80;
-
- const $ = (id) => document.getElementById(id);
- const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
- const uid = () => Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-5);
- const mod = (n, m) => ((n % m) + m) % m;
- const lerp = (a, b, t) => a + (b - a) * t;
-
-
- function toggleHidden(el, hidden) {
- if (el) el.hidden = hidden;
- }
-
- function currentRole() {
- return els.assetCategory?.value || 'nature';
- }
-
- function roleToCategory(role) {
- return role === 'human' || role === 'animal' ? 'dynamic' : 'static';
- }
-
- function roleToSubtype(role) {
- return role || 'other';
- }
-
- function subtypeToRole(asset) {
- const subtype = asset?.subtype;
- if (['human', 'animal', 'nature', 'building', 'other'].includes(subtype)) return subtype;
- if (subtype === 'water') return 'other';
- if (asset?.category === 'dynamic') return 'animal';
- return 'other';
- }
-
- const els = {
- canvas: $('worldCanvas'),
- openEditor: $('openEditor'),
- closeEditor: $('closeEditor'),
- drawer: $('studioDrawer'),
- tabs: [...document.querySelectorAll('.tab')],
- panels: [...document.querySelectorAll('.tabPanel')],
- phaseLabel: $('phaseLabel'),
- phaseBar: $('phaseBar'),
- authorName: $('authorName'),
- selectedAssetName: $('selectedAssetName'),
- tileInfo: $('tileInfo'),
- toast: $('toast'),
- selectionBubble: $('selectionBubble'), bubbleName: $('bubbleName'), bubbleAuthor: $('bubbleAuthor'), bubbleRemixFrom: $('bubbleRemixFrom'), bubbleRemixCount: $('bubbleRemixCount'), voteScore: $('voteScore'), voteUp: $('voteUp'), voteDown: $('voteDown'), bubbleRemix: $('bubbleRemix'), bubbleHide: $('bubbleHide'),
-
- modeInspect: $('modeInspect'), modePlace: $('modePlace'), modeErase: $('modeErase'),
- drawerInspect: $('drawerInspect'), drawerPlace: $('drawerPlace'), drawerErase: $('drawerErase'),
- zoomOut: $('zoomOut'), zoomIn: $('zoomIn'), resetView: $('resetView'),
- drawerZoomOut: $('drawerZoomOut'), drawerZoomIn: $('drawerZoomIn'), drawerResetView: $('drawerResetView'),
-
- assetName: $('assetName'),
- assetSize: $('assetSize'),
- assetCategory: $('assetCategory'),
- staticKindWrap: $('staticKindWrap'),
- dynamicKindWrap: $('dynamicKindWrap'),
- roleHint: $('roleHint'),
- sideSwitcher: $('sideSwitcher'),
- editRight: $('editRight'), editLeft: $('editLeft'),
- paintColor: $('paintColor'), toolBrush: $('toolBrush'), toolErase: $('toolErase'), toolFill: $('toolFill'), toolPick: $('toolPick'), toolLine: $('toolLine'), toolRect: $('toolRect'), toolSelect: $('toolSelect'), undoPaint: $('undoPaint'), redoPaint: $('redoPaint'),
- toolLight: $('toolLight'), toolDoor: $('toolDoor'), toolDepth: $('toolDepth'), toggleAdvanced: $('toggleAdvanced'), advancedHint: $('advancedHint'), clearPaint: $('clearPaint'), flipHorizontal: $('flipHorizontal'), flipVertical: $('flipVertical'), outlinePaint: $('outlinePaint'), clearSelection: $('clearSelection'), nudgeLeft: $('nudgeLeft'), nudgeRight: $('nudgeRight'), nudgeUp: $('nudgeUp'), nudgeDown: $('nudgeDown'), exportPng: $('exportPng'), importPng: $('importPng'), pngImportInput: $('pngImportInput'),
- paintCanvas: $('paintCanvas'), editHint: $('editHint'), paletteGrid: $('paletteGrid'),
- lightColor: $('lightColor'), staticSettingsPanel: $('staticSettingsPanel'), dynamicSettingsPanel: $('dynamicSettingsPanel'), doorMarkerHint: $('doorMarkerHint'),
- settingsSummary: $('settingsSummary'), saveAsset: $('saveAsset'), saveAndPlace: $('saveAndPlace'), newAsset: $('newAsset'),
- lineageNote: $('lineageNote'), assetList: $('assetList'), showHiddenAssets: $('showHiddenAssets'), hiddenAssetPanel: $('hiddenAssetPanel'), hiddenAssetList: $('hiddenAssetList'),
- exportData: $('exportData'), importData: $('importData'), resetAll: $('resetAll'), dataBox: $('dataBox'),
- exportCompact: $('exportCompact'), exportSnapshot: $('exportSnapshot'), exportAssetBundle: $('exportAssetBundle'), syncStats: $('syncStats')
- };
-
- const ctx = els.canvas.getContext('2d', { alpha: false });
- const pctx = els.paintCanvas.getContext('2d', { alpha: true });
- ctx.imageSmoothingEnabled = false;
- pctx.imageSmoothingEnabled = false;
-
- let dpr = window.devicePixelRatio || 1;
- let cw = 1;
- let ch = 1;
-
- let world = makeWorld();
- let terrainCache = buildTerrainCache(world);
- let state = loadState();
- let selectedAssetId = state.assets[0]?.id ?? null;
- let mode = 'inspect';
- let view = { x: 0, y: 0, zoom: 1 };
- let pointer = {
- down: false, id: null, startX: 0, startY: 0, lastX: 0, lastY: 0,
- dragging: false, downTime: 0
- };
- let hoverTile = null;
- let dynamicRuntime = [];
- let spriteCache = new Map();
- let lastRuntimeUpdate = performance.now();
- let lastClockSecond = -1;
- let toastTimer = null;
-
- let editorSize = 8;
- let editorPixels = blankPixels(8);
- let editorLeftPixels = blankPixels(8);
- let editingSide = 'right';
- let paintTool = 'brush';
- let selectedColorCode = 'a';
- let advancedDraw = false;
- let depthPixels = blankPixels(8).map(() => 0);
- let isPainting = false;
- let staticKind = 'nature';
- let dynamicKind = 'human';
- let lightPixels = [];
- let doorPixel = { x: 8, y: 15 };
- let editParentId = null;
- let editOriginalId = null;
- let editingAssetId = null;
- let lastPaintedKey = '';
- let spawnEffects = [];
- let bubbleParticles = [];
- let confettiParticles = [];
- let mousePaint = { active: false, panning: false, lastX: 0, lastY: 0, button: 0 };
- let shadowCanvasCache = new WeakMap();
- let animationFrameId = 0;
- let dynamicLogicRemainder = 0;
- let worldIndex = makeEmptyWorldIndex();
- let selectedObject = null;
- let libraryFilter = 'all';
- let renderPhase = null;
- let editorView = { zoom: 1, x: 0, y: 0 };
- let editorPointer = { panning: false, pointerId: null, lastX: 0, lastY: 0 };
- let editorHistory = [];
- let editorFuture = [];
- let editorGestureSnapshot = null;
- let editorGestureChanged = false;
- let suppressNextPaintClick = false;
- let suppressMousePaintUntil = 0;
- let editorSelection = null;
- let selectionGesture = null;
- let shapeGesture = null;
- let shapePreview = null;
-
- function bootstrap() {
- resizeCanvas();
- resetView(false);
- hydrateRuntime();
- wireUI();
- renderPalette();
- hydrateAuthorUI();
- refreshCategoryUI();
- setupEditor(8, blankPixels(8), null);
- clearEditorHistory();
- renderLibrary();
- updateSelectedLabel();
- rebuildWorldIndex();
- updateSyncStats();
- scheduleFrame();
- }
-
- function wireUI() {
- window.addEventListener('resize', () => {
- resizeCanvas();
- render();
- });
-
- els.openEditor.addEventListener('click', () => setDrawerOpen(true));
- els.closeEditor.addEventListener('click', () => setDrawerOpen(false));
-
- els.tabs.forEach((button) => {
- button.addEventListener('click', () => setTab(button.dataset.tab));
- });
-
- els.authorName?.addEventListener('input', () => {
- state.authorName = (els.authorName.value || 'Local Artist').trim() || 'Local Artist';
- saveState();
- renderLibrary();
- });
-
- [els.modeInspect, els.drawerInspect].filter(Boolean).forEach((el) => el.addEventListener('click', () => setMode('inspect')));
- [els.modePlace, els.drawerPlace].filter(Boolean).forEach((el) => el.addEventListener('click', () => setMode('place')));
- [els.modeErase, els.drawerErase].filter(Boolean).forEach((el) => el.addEventListener('click', () => setMode('erase')));
- [els.zoomOut, els.drawerZoomOut].filter(Boolean).forEach((el) => el.addEventListener('click', () => zoomAt(cw / 2, ch / 2, view.zoom / 1.2)));
- [els.zoomIn, els.drawerZoomIn].filter(Boolean).forEach((el) => el.addEventListener('click', () => zoomAt(cw / 2, ch / 2, view.zoom * 1.2)));
- [els.resetView, els.drawerResetView].filter(Boolean).forEach((el) => el.addEventListener('click', () => resetView(true)));
-
- els.canvas.addEventListener('pointerdown', onWorldPointerDown);
- els.canvas.addEventListener('pointermove', onWorldPointerMove);
- els.canvas.addEventListener('pointerup', onWorldPointerUp);
- els.canvas.addEventListener('pointercancel', onWorldPointerUp);
- els.canvas.addEventListener('wheel', onWorldWheel, { passive: false });
- els.canvas.addEventListener('contextmenu', (event) => event.preventDefault());
- document.addEventListener('contextmenu', onDocumentContextMenu);
- document.addEventListener('visibilitychange', onVisibilityChange);
-
- els.assetSize.addEventListener('change', () => {
- const oldSize = editorSize;
- const nextSize = Number(els.assetSize.value);
- commitEditorMutation(() => {
- const resizedRight = resizePixels(editorPixels, oldSize, nextSize);
- const resizedDepth = resizeDepthPixels(depthPixels, oldSize, nextSize);
- setupEditor(nextSize, resizedRight, null, resizedDepth);
- doorPixel = { x: Math.min(doorPixel.x, nextSize - 1), y: Math.min(doorPixel.y, nextSize - 1) };
- lightPixels = lightPixels.filter((p) => p.x < nextSize && p.y < nextSize);
- resetEditorView();
- return true;
- });
- updateSettingsSummary();
- });
-
- els.assetCategory.addEventListener('change', refreshCategoryUI);
- document.querySelectorAll('[data-static-kind]').forEach((button) => {
- button.addEventListener('click', () => {
- staticKind = button.dataset.staticKind;
- document.querySelectorAll('[data-static-kind]').forEach((b) => b.classList.toggle('active', b === button));
- refreshCategoryUI();
- });
- });
- document.querySelectorAll('[data-dynamic-kind]').forEach((button) => {
- button.addEventListener('click', () => {
- dynamicKind = button.dataset.dynamicKind;
- document.querySelectorAll('[data-dynamic-kind]').forEach((b) => b.classList.toggle('active', b === button));
- refreshCategoryUI();
- });
- });
-
- els.editRight.addEventListener('click', () => setEditingSide('right'));
- els.editLeft.addEventListener('click', () => setEditingSide('left'));
-
- els.toolBrush.addEventListener('click', () => setPaintTool('brush'));
- els.toolErase.addEventListener('click', () => setPaintTool('erase'));
- els.toolFill?.addEventListener('click', () => setPaintTool('fill'));
- els.toolPick?.addEventListener('click', () => setPaintTool('pick'));
- els.toolLine?.addEventListener('click', () => setPaintTool('line'));
- els.toolRect?.addEventListener('click', () => setPaintTool('rect'));
- els.toolSelect?.addEventListener('click', () => setPaintTool('select'));
- els.toolLight.addEventListener('click', () => setPaintTool('light'));
- els.toolDoor.addEventListener('click', () => setPaintTool('door'));
- els.toolDepth?.addEventListener('click', () => setPaintTool('depth'));
- els.toggleAdvanced?.addEventListener('click', () => {
- advancedDraw = !advancedDraw;
- toggleHidden(els.toolLight, !advancedDraw);
- toggleHidden(els.toolDepth, !advancedDraw);
- toggleHidden(els.advancedHint, !advancedDraw);
- els.toggleAdvanced.classList.toggle('active', advancedDraw);
- if (!advancedDraw && (paintTool === 'depth' || paintTool === 'light')) setPaintTool('brush');
- drawEditor();
- });
- els.clearPaint.addEventListener('click', () => {
- commitEditorMutation(() => {
- editorPixels = blankPixels(editorSize);
- editorLeftPixels = mirrorPixels(editorPixels, editorSize);
- depthPixels = blankPixels(editorSize).map(() => 0);
- lightPixels = [];
- editorSelection = null;
- return true;
- });
- });
- els.flipHorizontal?.addEventListener('click', flipEditorHorizontal);
- els.flipVertical?.addEventListener('click', flipEditorVertical);
- els.outlinePaint?.addEventListener('click', applyEditorOutline);
- els.clearSelection?.addEventListener('click', () => { editorSelection = null; drawEditor(); updateEditorSelectionButtons(); });
- els.nudgeLeft?.addEventListener('click', () => nudgeEditor(-1, 0));
- els.nudgeRight?.addEventListener('click', () => nudgeEditor(1, 0));
- els.nudgeUp?.addEventListener('click', () => nudgeEditor(0, -1));
- els.nudgeDown?.addEventListener('click', () => nudgeEditor(0, 1));
- els.exportPng?.addEventListener('click', exportEditorPng);
- els.importPng?.addEventListener('click', () => els.pngImportInput?.click());
- els.pngImportInput?.addEventListener('change', importEditorPng);
- els.undoPaint?.addEventListener('click', undoEditor);
- els.redoPaint?.addEventListener('click', redoEditor);
- window.addEventListener('keydown', onEditorKeyDown);
- els.voteUp?.addEventListener('click', () => voteSelected(1));
- els.voteDown?.addEventListener('click', () => voteSelected(-1));
- els.bubbleRemix?.addEventListener('click', () => remixSelected());
- els.bubbleHide?.addEventListener('click', () => hideSelected());
-
- els.paintCanvas.addEventListener('pointerdown', onPaintPointerDown);
- els.paintCanvas.addEventListener('pointermove', onPaintPointerMove);
- els.paintCanvas.addEventListener('pointerup', onPaintPointerUp);
- els.paintCanvas.addEventListener('pointercancel', onPaintPointerUp);
- els.paintCanvas.addEventListener('mousedown', onPaintMouseDown);
- window.addEventListener('mousemove', onPaintMouseMove);
- window.addEventListener('mouseup', onPaintMouseUp);
- els.paintCanvas.addEventListener('wheel', onPaintWheel, { passive: false });
- els.paintCanvas.addEventListener('click', onPaintClick);
- window.addEventListener('pointerup', () => { isPainting = false; lastPaintedKey = ''; editorPointer.panning = false; finishEditorGesture(); });
- els.paintCanvas.addEventListener('contextmenu', (event) => event.preventDefault());
-
- els.saveAsset.addEventListener('click', saveAssetFromEditor);
- els.saveAndPlace.addEventListener('click', saveAndPlaceFromEditor);
- els.newAsset.addEventListener('click', newAsset);
- els.exportData.addEventListener('click', exportData);
- els.importData.addEventListener('click', importData);
- els.exportCompact?.addEventListener('click', exportCompactData);
- els.exportSnapshot?.addEventListener('click', exportSnapshotData);
- els.exportAssetBundle?.addEventListener('click', exportAssetBundleData);
- els.resetAll.addEventListener('click', resetAll);
- els.showHiddenAssets?.addEventListener('click', () => {
- els.hiddenAssetPanel.hidden = !els.hiddenAssetPanel.hidden;
- renderHiddenAssets();
- });
- }
-
- function resizeCanvas() {
- dpr = window.devicePixelRatio || 1;
- cw = Math.max(1, window.innerWidth);
- ch = Math.max(1, window.innerHeight);
- els.canvas.width = Math.floor(cw * dpr);
- els.canvas.height = Math.floor(ch * dpr);
- els.canvas.style.width = `${cw}px`;
- els.canvas.style.height = `${ch}px`;
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
- }
-
- function resetView(announce) {
- const bounds = terrainCache.bounds;
- view.zoom = Math.min(1.05, Math.max(MIN_ZOOM, Math.min(cw / bounds.w, ch / bounds.h) * 1.18));
- view.x = cw / 2 - (bounds.x + bounds.w / 2) * view.zoom;
- view.y = ch / 2 - (bounds.y + bounds.h / 2) * view.zoom + 34;
- if (announce) toast('View reset.');
- }
-
- function setDrawerOpen(open) {
- els.drawer.classList.toggle('open', open);
- els.openEditor.hidden = open;
- if (!open) {
- selectedObject = null;
- updateSelectionBubble(performance.now());
- }
- }
-
- function onDocumentContextMenu(event) {
- if (!els.drawer?.classList.contains('open')) return;
- if (els.drawer.contains(event.target) || els.openEditor.contains(event.target)) return;
- event.preventDefault();
- setDrawerOpen(false);
- }
-
- function setTab(name) {
- els.tabs.forEach((button) => button.classList.toggle('active', button.dataset.tab === name));
- els.panels.forEach((panel) => panel.classList.toggle('active', panel.id === `tab-${name}`));
- }
-
- function setMode(nextMode) {
- mode = nextMode;
- const isInspect = mode === 'inspect';
- const isPlace = mode === 'place';
- const isErase = mode === 'erase';
- [els.modeInspect, els.drawerInspect].filter(Boolean).forEach((el) => el.classList.toggle('active', isInspect));
- [els.modePlace, els.drawerPlace].filter(Boolean).forEach((el) => el.classList.toggle('active', isPlace));
- [els.modeErase, els.drawerErase].filter(Boolean).forEach((el) => el.classList.toggle('active', isErase));
- els.canvas.classList.toggle('placeCursor', isPlace);
- els.canvas.classList.toggle('eraseCursor', isErase);
- }
-
- function refreshCategoryUI() {
- const role = currentRole();
- const isStatic = roleToCategory(role) === 'static';
- if (isStatic) staticKind = roleToSubtype(role);
- else dynamicKind = roleToSubtype(role);
-
- toggleHidden(els.staticKindWrap, true);
- toggleHidden(els.dynamicKindWrap, true);
- toggleHidden(els.sideSwitcher, isStatic);
- toggleHidden(els.staticSettingsPanel, !isStatic);
- toggleHidden(els.dynamicSettingsPanel, true);
- toggleHidden(els.toolLight, !advancedDraw);
- toggleHidden(els.toolDepth, !advancedDraw);
- toggleHidden(els.toolDoor, role !== 'building');
- toggleHidden(els.doorMarkerHint, role !== 'building');
-
- if (!advancedDraw && (paintTool === 'light' || paintTool === 'depth')) setPaintTool('brush');
- if (!isStatic && paintTool === 'door') setPaintTool('brush');
- if (isStatic && role !== 'building' && paintTool === 'door') setPaintTool('brush');
- if (isStatic) editingSide = 'right';
-
- updateRoleHint();
- updateSideButtons();
- clampEditorView();
- drawEditor();
- updateSettingsSummary();
- }
-
- function updateRoleHint() {
- if (!els.roleHint) return;
- const role = currentRole();
- const textMap = {
- human: 'Humans need ▶ Right and ◀ Left sprites. They hop often and visit buildings.',
- animal: 'Animals need ▶ Right and ◀ Left sprites. They hop often and prefer nature.',
- nature: 'Nature attracts animals and birds. Static sprites are drawn at 2× scale.',
- building: 'Buildings attract humans. Use Door to mark the entrance.',
- other: 'Other objects are neutral scenery and render at 2× scale.'
- };
- els.roleHint.textContent = textMap[role] || textMap.other;
- }
-
- function updateSettingsSummary() {
- if (!els.settingsSummary) return;
- const role = currentRole();
- if (roleToCategory(role) === 'static') {
- const lightCount = lightPixels.length;
- const lightText = lightCount ? `${lightCount} lamp cell${lightCount === 1 ? '' : 's'}` : 'no light';
- const doorText = role === 'building' ? ` / door ${Math.round(doorPixel.x)},${Math.round(doorPixel.y)}` : '';
- els.settingsSummary.textContent = `${cap(role)} / 2× static pixels / ${lightText}${doorText}.`;
- } else {
- els.settingsSummary.textContent = `${cap(role)} / canonical ▶ Right / ◀ Left is an auto-mirrored preview and movement-facing state.`;
- }
- }
-
- function setPaintTool(tool) {
- paintTool = tool;
- [els.toolBrush, els.toolErase, els.toolFill, els.toolPick, els.toolLine, els.toolRect, els.toolSelect, els.toolLight, els.toolDoor, els.toolDepth].filter(Boolean).forEach((button) => button.classList.remove('active'));
- ({ brush: els.toolBrush, erase: els.toolErase, fill: els.toolFill, pick: els.toolPick, line: els.toolLine, rect: els.toolRect, select: els.toolSelect, light: els.toolLight, door: els.toolDoor, depth: els.toolDepth }[tool])?.classList.add('active');
- const hints = {
- brush: 'Draw pixels with the selected palette color.',
- erase: 'Erase pixels. Erasing also clears light markers on that cell.',
- fill: 'Fill a connected area with the selected palette color. Hold Shift to fill with transparency.',
- pick: 'Pick a color from the canvas and return to Draw.',
- line: 'Drag to draw a straight line. Right-click erases along the line.',
- rect: 'Drag to draw a rectangle. Hold Shift for a filled rectangle; right-click erases.',
- select: 'Drag a rectangle to select pixels, then drag or nudge the selection.',
- light: 'Paint light cells with the selected palette color. Hold Shift to erase light cells.',
- door: 'Click one pixel to mark a building door. Humans will enter near this point.',
- depth: 'Advanced: paint depth values onto pixels. Shift/right-click erases depth.'
- };
- els.editHint.textContent = hints[tool] || hints.brush;
- }
-
- function setEditingSide(side) {
- if (roleToCategory(currentRole()) !== 'dynamic') return;
- editingSide = side;
- updateSideButtons();
- drawEditor();
- }
-
- function updateSideButtons() {
- if (els.editRight) els.editRight.classList.toggle('active', editingSide === 'right');
- if (els.editLeft) els.editLeft.classList.toggle('active', editingSide === 'left');
- updateSettingsSummary();
- }
-
- function onWorldPointerDown(event) {
- event.preventDefault();
- els.canvas.setPointerCapture(event.pointerId);
- pointer = {
- down: true,
- id: event.pointerId,
- startX: event.clientX,
- startY: event.clientY,
- lastX: event.clientX,
- lastY: event.clientY,
- dragging: false,
- downTime: performance.now()
- };
- els.canvas.classList.add('dragging');
- }
-
- function onWorldPointerMove(event) {
- const pos = getCanvasPoint(event);
- hoverTile = screenToTile(pos.x, pos.y);
- updateTileInfo();
-
- if (!pointer.down || pointer.id !== event.pointerId) return;
- const dx = event.clientX - pointer.lastX;
- const dy = event.clientY - pointer.lastY;
- const total = Math.hypot(event.clientX - pointer.startX, event.clientY - pointer.startY);
- if (total > 3) pointer.dragging = true;
- if (pointer.dragging) {
- view.x += dx;
- view.y += dy;
- }
- pointer.lastX = event.clientX;
- pointer.lastY = event.clientY;
- }
-
- function onWorldPointerUp(event) {
- if (!pointer.down || pointer.id !== event.pointerId) return;
- els.canvas.classList.remove('dragging');
- const total = Math.hypot(event.clientX - pointer.startX, event.clientY - pointer.startY);
- const elapsed = performance.now() - pointer.downTime;
- const wasClick = total < 6 && elapsed < 600;
- const pos = getCanvasPoint(event);
- const tile = screenToTile(pos.x, pos.y);
- if (wasClick) {
- const pickedObject = pickObjectAtScreen(pos.x, pos.y, performance.now());
- if (mode === 'place' && tile) placeSelected(tile.x, tile.y);
- else if (mode === 'erase') {
- if (pickedObject) eraseObject(pickedObject.kind, pickedObject.id);
- else if (tile) eraseAt(tile.x, tile.y);
- } else if (pickedObject) {
- selectWorldObject(pickedObject.kind, pickedObject.id, pickedObject.assetId, performance.now());
- const asset = findAsset(pickedObject.assetId);
- toast(asset ? `Selected ${asset.name}.` : 'Selected object.');
- } else if (tile) inspectAt(tile.x, tile.y);
- }
- pointer.down = false;
- pointer.id = null;
- pointer.dragging = false;
- }
-
- function onWorldWheel(event) {
- event.preventDefault();
- const pos = getCanvasPoint(event);
- const factor = event.deltaY > 0 ? 1 / 1.13 : 1.13;
- zoomAt(pos.x, pos.y, view.zoom * factor);
- }
-
- function zoomAt(screenX, screenY, nextZoom) {
- nextZoom = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM);
- const worldX = (screenX - view.x) / view.zoom;
- const worldY = (screenY - view.y) / view.zoom;
- view.zoom = nextZoom;
- view.x = screenX - worldX * view.zoom;
- view.y = screenY - worldY * view.zoom;
- }
-
- function getCanvasPoint(event) {
- const rect = els.canvas.getBoundingClientRect();
- return { x: event.clientX - rect.left, y: event.clientY - rect.top };
- }
-
- function screenToTile(screenX, screenY) {
- const worldX = (screenX - view.x) / view.zoom;
- const worldY = (screenY - view.y) / view.zoom;
- const localX = worldX - ORIGIN_X;
- const localY = worldY - ORIGIN_Y;
- const a = localX / (TILE_W / 2);
- const b = localY / (TILE_H / 2);
- const baseX = Math.floor((a + b) / 2);
- const baseY = Math.floor((b - a) / 2);
- let best = null;
- let bestScore = -Infinity;
- for (let ty = baseY - 2; ty <= baseY + 2; ty++) {
- for (let tx = baseX - 2; tx <= baseX + 2; tx++) {
- if (tx < 0 || ty < 0 || tx >= WORLD_W || ty >= WORLD_H) continue;
- const tile = world.get(tx, ty);
- const center = tileToWorld(tx, ty);
- const lift = getTileLift(tile);
- const nx = Math.abs(worldX - center.x) / (TILE_W / 2);
- const ny = Math.abs(worldY - (center.y + TILE_H / 2 - lift)) / (TILE_H / 2);
- if (nx + ny <= 1) {
- const score = lift * 100 + (tx + ty);
- if (score > bestScore) {
- bestScore = score;
- best = { x: tx, y: ty, tile };
- }
- }
- }
- }
- return best;
- }
-
- function tileToWorld(x, y) {
- return {
- x: (x - y) * TILE_W / 2 + ORIGIN_X,
- y: (x + y) * TILE_H / 2 + ORIGIN_Y
- };
- }
-
- function tileKey(x, y) {
- return `${x},${y}`;
- }
-
- function makeEmptyWorldIndex() {
- return { placedByTile: new Map(), dynamicByHomeTile: new Map() };
- }
-
- function pushIndexBucket(map, key, item) {
- const bucket = map.get(key);
- if (bucket) bucket.push(item);
- else map.set(key, [item]);
- }
-
- function rebuildWorldIndex() {
- const next = makeEmptyWorldIndex();
- for (const placed of state.placed || []) {
- pushIndexBucket(next.placedByTile, tileKey(placed.x, placed.y), placed);
- }
- for (const summon of state.dynamicSummons || []) {
- pushIndexBucket(next.dynamicByHomeTile, tileKey(Math.round(summon.homeX), Math.round(summon.homeY)), summon);
- }
- worldIndex = next;
- }
-
- function getPlacedAtTile(x, y) {
- return worldIndex.placedByTile.get(tileKey(x, y)) || [];
- }
-
- function getDynamicHomesAtTile(x, y) {
- return worldIndex.dynamicByHomeTile.get(tileKey(x, y)) || [];
- }
-
-
- function getDrawableItems(time = performance.now(), viewport = null) {
- const rect = viewport || getViewportWorldRect(VIEW_CULL_MARGIN);
- const items = [];
- for (const placed of state.placed) {
- const asset = findAsset(placed.assetId);
- if (!asset || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[placed.id]) continue;
- const itemX = placed.x + .5;
- const itemY = placed.y + .5;
- if (!isApproxVisible(asset, itemX, itemY, rect)) continue;
- items.push({ kind: 'static', asset, x: itemX, y: itemY, source: placed });
- }
- for (const runtime of dynamicRuntime) {
- const asset = findAsset(runtime.assetId);
- if (!asset || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[runtime.id]) continue;
- if (time < runtime.hiddenUntil) continue;
- if (!isApproxVisible(asset, runtime.x, runtime.y, rect)) continue;
- items.push({ kind: 'dynamic', asset, x: runtime.x, y: runtime.y, source: runtime });
- }
- items.sort(drawOrderCompare);
- return items;
- }
-
- function drawOrderCompare(a, b) {
- return (a.x + a.y) - (b.x + b.y) || a.y - b.y || String(a.source?.id || '').localeCompare(String(b.source?.id || ''));
- }
-
- function getViewportWorldRect(margin = 0) {
- const invZoom = 1 / Math.max(0.0001, view.zoom);
- return {
- left: -view.x * invZoom - margin,
- top: -view.y * invZoom - margin,
- right: (cw - view.x) * invZoom + margin,
- bottom: (ch - view.y) * invZoom + margin
- };
- }
-
- function isApproxVisible(asset, x, y, rect) {
- if (!rect) return true;
- const pos = tileToWorld(x, y);
- if (!(asset.category === 'dynamic' && asset.subtype === 'bird')) pos.y -= getLiftAtCoord(x, y);
- const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE;
- const w = asset.size * scale + 96;
- const h = asset.size * scale + 128;
- return pos.x + w >= rect.left && pos.x - w <= rect.right && pos.y + 80 >= rect.top && pos.y - h <= rect.bottom;
- }
-
-
- function pickObjectAtScreen(screenX, screenY, time = performance.now()) {
- const worldX = (screenX - view.x) / view.zoom;
- const worldY = (screenY - view.y) / view.zoom;
- const items = getDrawableItems(time, getViewportWorldRect(256));
- for (let i = items.length - 1; i >= 0; i--) {
- const item = items[i];
- const info = getSpriteDrawInfo(item, time, false);
- const margin = item.kind === 'dynamic' ? 18 : 8;
- const left = info.drawX - margin;
- const top = info.drawY - margin;
- const right = info.drawX + info.sprite.width + margin;
- const bottom = info.drawY + info.sprite.height + margin;
- if (worldX >= left && worldX <= right && worldY >= top && worldY <= bottom) {
- return { kind: item.kind, id: item.source.id, assetId: item.asset.id };
- }
- }
- return null;
- }
-
- function updateTileInfo() {
- if (!els.tileInfo) return;
- if (!hoverTile) {
- els.tileInfo.textContent = 'Move over the map.';
- return;
- }
- const tile = world.get(hoverTile.x, hoverTile.y);
- const staticCount = getPlacedAtTile(hoverTile.x, hoverTile.y).length;
- const dynamicCount = getDynamicHomesAtTile(hoverTile.x, hoverTile.y).length;
- els.tileInfo.textContent = `Tile ${hoverTile.x}, ${hoverTile.y}\nTerrain: ${cap(tile.type)}\nObjects here: ${staticCount}\nDynamic homes: ${dynamicCount}`;
- }
-
- function inspectAt(x, y) {
- const tile = world.get(x, y);
- const staticObjects = getPlacedAtTile(x, y);
- const dynamicObjects = getDynamicHomesAtTile(x, y);
- const picked = [...dynamicObjects.map((p) => ({ ...p, objectKind: 'dynamic' })), ...staticObjects.map((p) => ({ ...p, objectKind: 'static' }))].at(-1);
- if (picked) {
- selectWorldObject(picked.objectKind, picked.id, picked.assetId, performance.now());
- const asset = findAsset(picked.assetId);
- toast(asset ? `Selected ${asset.name}.` : 'Selected object.');
- } else {
- selectedObject = null;
- updateSelectionBubble(performance.now());
- }
- const staticNames = staticObjects.map((p) => findAsset(p.assetId)?.name).filter(Boolean);
- const dynamicNames = dynamicObjects.map((p) => findAsset(p.assetId)?.name).filter(Boolean);
- const lines = [`Tile ${x}, ${y}`, `Terrain: ${cap(tile.type)}`];
- if (staticNames.length) lines.push(`Static: ${staticNames.join(', ')}`);
- if (dynamicNames.length) lines.push(`Dynamic homes: ${dynamicNames.join(', ')}`);
- if (els.tileInfo) els.tileInfo.textContent = lines.join('\n');
- }
-
- function selectWorldObject(kind, objectId, assetId, selectedAt = performance.now()) {
- selectedObject = { kind, id: objectId, assetId, selectedAt };
- selectedAssetId = assetId;
- updateSelectedLabel();
- renderLibrary();
- updateSelectionBubble(selectedAt);
- }
-
- function placeSelected(x, y) {
- const asset = findAsset(selectedAssetId);
- if (!asset) {
- toast('Select an asset first.');
- setDrawerOpen(true);
- setTab('library');
- return;
- }
- const tile = world.get(x, y);
- if (!canPlace(asset, tile)) return;
-
- if (asset.category === 'static') {
- const existing = state.placed.find((p) => p.assetId === asset.id);
- if (existing) {
- existing.x = x; existing.y = y; existing.placedAt = Date.now(); existing.version = (existing.version || 1) + 1;
- selectWorldObject('static', existing.id, asset.id, performance.now());
- toast(`${asset.name} moved.`);
- } else {
- const placed = { id: uid(), assetId: asset.id, x, y, placedAt: Date.now(), version: 1 };
- state.placed.push(placed);
- selectWorldObject('static', placed.id, asset.id, performance.now());
- toast(`${asset.name} placed.`);
- }
- } else {
- const existing = state.dynamicSummons.find((p) => p.assetId === asset.id);
- if (existing) {
- existing.homeX = x; existing.homeY = y; existing.createdAt = Date.now(); existing.version = (existing.version || 1) + 1;
- toast(`${asset.name} moved.`);
- } else {
- const summon = { id: uid(), assetId: asset.id, homeX: x, homeY: y, createdAt: Date.now(), version: 1 };
- state.dynamicSummons.push(summon);
- toast(`${asset.name} summoned.`);
- }
- hydrateRuntime();
- const found = state.dynamicSummons.find((p) => p.assetId === asset.id);
- if (found) selectWorldObject('dynamic', found.id, asset.id, performance.now());
- }
- const object = asset.category === 'static'
- ? state.placed.find((p) => p.assetId === asset.id)
- : state.dynamicSummons.find((p) => p.assetId === asset.id);
- rebuildWorldIndex();
- recordSyncEvent(Phase2Sync?.createObjectUpsertEvent?.(asset.category === 'dynamic' ? 'dynamic' : 'static', object));
- spawnEffects.push({ x: x + .5, y: y + .5, started: performance.now() });
- saveState();
- }
-
- function canPlace(asset, tile) {
- if (!tile) return false;
- if (asset.category === 'static' && asset.subtype === 'water' && tile.type !== 'water') {
- toast('Water objects need water tiles.');
- return false;
- }
- if (asset.category === 'dynamic' && asset.subtype === 'fish' && tile.type !== 'water') {
- toast('Fish need water tiles.');
- return false;
- }
- if (asset.category !== 'dynamic' || asset.subtype !== 'fish') {
- if (tile.type === 'water' && !(asset.category === 'static' && asset.subtype === 'water')) {
- toast('Use land for this asset.');
- return false;
- }
- }
- return true;
- }
-
- function eraseAt(x, y) {
- const staticObjects = getPlacedAtTile(x, y);
- const staticTarget = staticObjects.at(-1);
- if (staticTarget) {
- const index = state.placed.findIndex((p) => p.id === staticTarget.id);
- if (index >= 0) {
- const item = state.placed[index];
- const asset = findAsset(item.assetId);
- state.placed.splice(index, 1);
- rebuildWorldIndex();
- recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('static', item.id));
- if (selectedObject?.id === item.id) selectedObject = null;
- saveState();
- toast(`${asset?.name ?? 'Object'} removed.`);
- return;
- }
- }
- const dynamicObjects = getDynamicHomesAtTile(x, y);
- const dynamicTarget = dynamicObjects.at(-1);
- if (dynamicTarget) {
- const index = state.dynamicSummons.findIndex((p) => p.id === dynamicTarget.id);
- if (index >= 0) {
- const item = state.dynamicSummons[index];
- const asset = findAsset(item.assetId);
- state.dynamicSummons.splice(index, 1);
- rebuildWorldIndex();
- recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('dynamic', item.id));
- if (selectedObject?.id === item.id) selectedObject = null;
- hydrateRuntime();
- saveState();
- toast(`${asset?.name ?? 'Dynamic object'} removed.`);
- return;
- }
- }
- toast('Nothing to erase here.');
- }
-
-
-
- function eraseObject(kind, objectId) {
- if (kind === 'static') {
- const index = state.placed.findIndex((p) => p.id === objectId);
- if (index >= 0) {
- const asset = findAsset(state.placed[index].assetId);
- state.placed.splice(index, 1);
- rebuildWorldIndex();
- recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('static', objectId));
- if (selectedObject?.id === objectId) selectedObject = null;
- saveState();
- updateSelectionBubble(performance.now());
- toast(`${asset?.name ?? 'Object'} removed.`);
- return;
- }
- }
- if (kind === 'dynamic') {
- const index = state.dynamicSummons.findIndex((p) => p.id === objectId);
- if (index >= 0) {
- const asset = findAsset(state.dynamicSummons[index].assetId);
- state.dynamicSummons.splice(index, 1);
- rebuildWorldIndex();
- recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('dynamic', objectId));
- if (selectedObject?.id === objectId) selectedObject = null;
- hydrateRuntime();
- saveState();
- updateSelectionBubble(performance.now());
- toast(`${asset?.name ?? 'Dynamic object'} removed.`);
- }
- }
- }
-
- function onPaintPointerDown(event) {
- event.preventDefault();
- els.paintCanvas.setPointerCapture?.(event.pointerId);
- lastPaintedKey = '';
- const isPan = event.button === 1 || event.button === 2;
- editorPointer = { panning: isPan, pointerId: event.pointerId, lastX: event.clientX, lastY: event.clientY };
- isPainting = !isPan;
- mousePaint.active = false;
- mousePaint.panning = false;
- if (isPainting) {
- suppressNextPaintClick = true;
- suppressMousePaintUntil = performance.now() + 350;
- if (handleEditorSpecialPointerDown(event)) return;
- beginEditorGesture();
- paintAtClient(event.clientX, event.clientY, event.button || 0, event.shiftKey);
- if (paintTool === 'fill' || paintTool === 'pick') { isPainting = false; finishEditorGesture(); }
- }
- }
-
- function onPaintPointerMove(event) {
- if (editorPointer.pointerId !== event.pointerId) return;
- if (editorPointer.panning) {
- event.preventDefault();
- const rect = els.paintCanvas.getBoundingClientRect();
- const sx = els.paintCanvas.width / rect.width;
- const sy = els.paintCanvas.height / rect.height;
- editorView.x += (event.clientX - editorPointer.lastX) * sx;
- editorView.y += (event.clientY - editorPointer.lastY) * sy;
- editorPointer.lastX = event.clientX;
- editorPointer.lastY = event.clientY;
- clampEditorView();
- drawEditor();
- return;
- }
- if (shapeGesture || selectionGesture) {
- event.preventDefault();
- handleEditorSpecialPointerMove(event);
- return;
- }
- if (!isPainting) return;
- if (event.buttons !== undefined && (event.buttons & 1) === 0) return;
- event.preventDefault();
- paintAtClient(event.clientX, event.clientY, 0, event.shiftKey);
- }
-
- function onPaintPointerUp(event) {
- if (editorPointer.pointerId === event.pointerId) editorPointer.panning = false;
- if (shapeGesture || selectionGesture) handleEditorSpecialPointerUp(event);
- isPainting = false;
- lastPaintedKey = '';
- finishEditorGesture();
- }
-
- function onPaintClick(event) {
- if (event.button !== 0) return;
- if (suppressNextPaintClick) { suppressNextPaintClick = false; return; }
- event.preventDefault();
- lastPaintedKey = '';
- if (paintTool === 'line' || paintTool === 'rect' || paintTool === 'select') return;
- beginEditorGesture();
- paintAtClient(event.clientX, event.clientY, 0, event.shiftKey);
- finishEditorGesture();
- }
-
- function onPaintWheel(event) {
- event.preventDefault();
- const rect = els.paintCanvas.getBoundingClientRect();
- const scaleX = els.paintCanvas.width / rect.width;
- const scaleY = els.paintCanvas.height / rect.height;
- const sx = (event.clientX - rect.left) * scaleX;
- const sy = (event.clientY - rect.top) * scaleY;
- const worldX = (sx - editorView.x) / editorView.zoom;
- const worldY = (sy - editorView.y) / editorView.zoom;
- const nextZoom = clamp(editorView.zoom * (event.deltaY > 0 ? 1 / 1.12 : 1.12), 1, 24);
- editorView.zoom = nextZoom;
- editorView.x = sx - worldX * editorView.zoom;
- editorView.y = sy - worldY * editorView.zoom;
- clampEditorView();
- drawEditor();
- }
-
- function onPaintMouseDown(event) {
- // Fallback for browsers/extensions where pointer events are swallowed.
- if (event.button !== 0 && event.button !== 1 && event.button !== 2) return;
- event.preventDefault();
- lastPaintedKey = '';
- const isPan = event.button === 1 || event.button === 2;
- mousePaint = { active: !isPan, panning: isPan, lastX: event.clientX, lastY: event.clientY, button: event.button };
- if (performance.now() < suppressMousePaintUntil) return;
- if (mousePaint.active) {
- beginEditorGesture();
- paintAtClient(event.clientX, event.clientY, event.button, event.shiftKey);
- if (paintTool === 'fill' || paintTool === 'pick') { mousePaint.active = false; finishEditorGesture(); }
- }
- }
-
- function onPaintMouseMove(event) {
- if (!mousePaint.active && !mousePaint.panning) return;
- event.preventDefault();
- if (mousePaint.panning) {
- const rect = els.paintCanvas.getBoundingClientRect();
- const sx = els.paintCanvas.width / rect.width;
- const sy = els.paintCanvas.height / rect.height;
- editorView.x += (event.clientX - mousePaint.lastX) * sx;
- editorView.y += (event.clientY - mousePaint.lastY) * sy;
- mousePaint.lastX = event.clientX;
- mousePaint.lastY = event.clientY;
- clampEditorView();
- drawEditor();
- return;
- }
- paintAtClient(event.clientX, event.clientY, mousePaint.button || 0, event.shiftKey);
- }
-
- function onPaintMouseUp() {
- mousePaint.active = false;
- mousePaint.panning = false;
- lastPaintedKey = '';
- finishEditorGesture();
- }
-
- function paintAtEvent(event) {
- paintAtClient(event.clientX, event.clientY, event.button || 0, event.shiftKey);
- }
-
- function paintAtClient(clientX, clientY, button = 0, shiftKey = false) {
- const local = clientToPaintLocal(clientX, clientY);
- const x = Math.floor(local.x);
- const y = Math.floor(local.y);
- if (x < 0 || y < 0 || x >= editorSize || y >= editorSize) return;
- const key = `${x},${y},${paintTool},${button},${shiftKey}`;
- if (key === lastPaintedKey && paintTool !== 'fill') return;
- lastPaintedKey = key;
-
- const point = displayCellToCanonical(x, y);
- const index = point.y * editorSize + point.x;
- let changed = false;
-
- if (paintTool === 'pick') {
- const picked = editorPixels[index];
- if (picked) selectPaletteCode(picked);
- setPaintTool('brush');
- drawEditor();
- return;
- }
-
- if (paintTool === 'fill') {
- const displayPixels = getDisplayEditorPixels();
- const fillColor = shiftKey ? null : selectedColorCode;
- const nextDisplay = EditorActions?.floodFill
- ? EditorActions.floodFill(displayPixels, editorSize, x, y, fillColor)
- : fallbackFloodFill(displayPixels, editorSize, x, y, fillColor);
- if (nextDisplay.changed) {
- editorPixels = editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic'
- ? mirrorPixels(nextDisplay.pixels, editorSize)
- : nextDisplay.pixels;
- editorLeftPixels = mirrorPixels(editorPixels, editorSize);
- if (fillColor === null && Array.isArray(nextDisplay.cells)) {
- for (const cell of nextDisplay.cells) {
- const src = displayCellToCanonical(cell.x, cell.y);
- removeLightPixel(src.x, src.y);
- }
- }
- changed = true;
- }
- } else if (paintTool === 'brush') {
- if (editorPixels[index] !== selectedColorCode) {
- editorPixels[index] = selectedColorCode;
- changed = true;
- }
- } else if (paintTool === 'erase') {
- if (editorPixels[index] !== null) {
- editorPixels[index] = null;
- changed = true;
- }
- const before = lightPixels.length;
- removeLightPixel(point.x, point.y);
- changed = changed || before !== lightPixels.length;
- } else if (paintTool === 'light') {
- const before = JSON.stringify(lightPixels);
- if (shiftKey || button === 2) removeLightPixel(point.x, point.y);
- else addLightPixel(point.x, point.y, selectedColorCode);
- changed = JSON.stringify(lightPixels) !== before;
- updateSettingsSummary();
- } else if (paintTool === 'depth') {
- const next = (shiftKey || button === 2) ? 0 : 1;
- if (depthPixels[index] !== next) {
- depthPixels[index] = next;
- changed = true;
- }
- } else if (paintTool === 'door') {
- if (roleToCategory(currentRole()) === 'static' && currentRole() === 'building') {
- if (doorPixel.x !== x || doorPixel.y !== y) {
- doorPixel = { x, y };
- changed = true;
- }
- updateSettingsSummary();
- }
- }
-
- if (changed) {
- editorLeftPixels = mirrorPixels(editorPixels, editorSize);
- markEditorChanged();
- drawEditor();
- }
- }
-
- function paintEventToCell(event) {
- const local = paintEventToLocal(event);
- return { x: Math.floor(local.x), y: Math.floor(local.y) };
- }
-
- function paintEventToLocal(event) {
- return clientToPaintLocal(event.clientX, event.clientY);
- }
-
- function clientToPaintLocal(clientX, clientY) {
- const rect = els.paintCanvas.getBoundingClientRect();
- const scaleX = els.paintCanvas.width / rect.width;
- const scaleY = els.paintCanvas.height / rect.height;
- const sx = (clientX - rect.left) * scaleX;
- const sy = (clientY - rect.top) * scaleY;
- const cell = els.paintCanvas.width / editorSize;
- return {
- x: (sx - editorView.x) / editorView.zoom / cell,
- y: (sy - editorView.y) / editorView.zoom / cell
- };
- }
-
- function snapshotEditorState() {
- return {
- size: editorSize,
- rightPixels: [...editorPixels],
- depthPixels: [...depthPixels],
- lightPixels: lightPixels.map((p) => ({ ...p })),
- doorPixel: { ...doorPixel },
- editingSide,
- selectedColorCode,
- editorSelection: editorSelection ? { ...editorSelection } : null
- };
- }
-
- function restoreEditorState(snapshot) {
- editorSize = snapshot.size || editorSize;
- editorPixels = normalizePixels(snapshot.rightPixels || [], editorSize);
- editorLeftPixels = mirrorPixels(editorPixels, editorSize);
- depthPixels = normalizeDepthPixels(snapshot.depthPixels || [], editorSize);
- lightPixels = Array.isArray(snapshot.lightPixels) ? snapshot.lightPixels.map((p) => ({ ...p })) : [];
- doorPixel = snapshot.doorPixel ? { ...snapshot.doorPixel } : { x: Math.floor(editorSize / 2), y: editorSize - 1 };
- editingSide = snapshot.editingSide || editingSide;
- if (snapshot.selectedColorCode) selectedColorCode = snapshot.selectedColorCode;
- editorSelection = snapshot.editorSelection ? normalizeSelectionRect(snapshot.editorSelection.x, snapshot.editorSelection.y, snapshot.editorSelection.x + snapshot.editorSelection.w - 1, snapshot.editorSelection.y + snapshot.editorSelection.h - 1) : null;
- els.assetSize.value = String(editorSize);
- updateSideButtons();
- renderPalette();
- drawEditor();
- updateSettingsSummary();
- updateEditorHistoryButtons();
- updateEditorSelectionButtons();
- }
-
- function beginEditorGesture() {
- if (!editorGestureSnapshot) {
- editorGestureSnapshot = snapshotEditorState();
- editorGestureChanged = false;
- }
- }
-
- function markEditorChanged() {
- editorGestureChanged = true;
- }
-
- function finishEditorGesture() {
- if (!editorGestureSnapshot) return;
- if (editorGestureChanged) {
- editorHistory.push(editorGestureSnapshot);
- if (editorHistory.length > EDITOR_HISTORY_LIMIT) editorHistory.shift();
- editorFuture = [];
- }
- editorGestureSnapshot = null;
- editorGestureChanged = false;
- updateEditorHistoryButtons();
- }
-
- function commitEditorMutation(mutator) {
- const before = snapshotEditorState();
- const changed = Boolean(mutator());
- if (changed) {
- editorHistory.push(before);
- if (editorHistory.length > EDITOR_HISTORY_LIMIT) editorHistory.shift();
- editorFuture = [];
- editorLeftPixels = mirrorPixels(editorPixels, editorSize);
- drawEditor();
- updateSettingsSummary();
- updateEditorHistoryButtons();
- }
- return changed;
- }
-
- function clearEditorHistory() {
- editorHistory = [];
- editorFuture = [];
- editorGestureSnapshot = null;
- editorGestureChanged = false;
- updateEditorHistoryButtons();
- }
-
- function undoEditor() {
- if (!editorHistory.length) return;
- editorFuture.push(snapshotEditorState());
- restoreEditorState(editorHistory.pop());
- }
-
- function redoEditor() {
- if (!editorFuture.length) return;
- editorHistory.push(snapshotEditorState());
- restoreEditorState(editorFuture.pop());
- }
-
- function updateEditorHistoryButtons() {
- if (els.undoPaint) els.undoPaint.disabled = editorHistory.length === 0;
- if (els.redoPaint) els.redoPaint.disabled = editorFuture.length === 0;
- updateEditorSelectionButtons();
- }
-
- function onEditorKeyDown(event) {
- if (!els.drawer?.classList.contains('open')) return;
- const key = event.key.toLowerCase();
- const activeTag = document.activeElement?.tagName?.toLowerCase();
- if (activeTag === 'input' || activeTag === 'textarea' || activeTag === 'select') return;
- if ((event.ctrlKey || event.metaKey) && key === 'z') {
- event.preventDefault();
- if (event.shiftKey) redoEditor();
- else undoEditor();
- } else if ((event.ctrlKey || event.metaKey) && key === 'y') {
- event.preventDefault();
- redoEditor();
- } else if (editorSelection && ['arrowleft', 'arrowright', 'arrowup', 'arrowdown'].includes(key)) {
- event.preventDefault();
- const step = event.shiftKey ? 4 : 1;
- const delta = { arrowleft: [-step, 0], arrowright: [step, 0], arrowup: [0, -step], arrowdown: [0, step] }[key];
- moveSelectionBy(delta[0], delta[1]);
- } else if (key === 'escape') {
- editorSelection = null;
- drawEditor();
- updateEditorSelectionButtons();
- } else if (!event.ctrlKey && !event.metaKey && !event.altKey) {
- const shortcuts = { b: 'brush', e: 'erase', f: 'fill', i: 'pick', l: 'line', r: 'rect', s: 'select' };
- if (shortcuts[key]) { event.preventDefault(); setPaintTool(shortcuts[key]); }
- }
- }
-
- function fallbackFloodFill(sourcePixels, size, x, y, colorCode) {
- const pixels = [...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 handleEditorSpecialPointerDown(event) {
- const cell = clientToCell(event.clientX, event.clientY);
- if (!cell) return false;
- if (paintTool === 'line' || paintTool === 'rect') {
- beginEditorGesture();
- shapeGesture = {
- tool: paintTool,
- startX: cell.x,
- startY: cell.y,
- endX: cell.x,
- endY: cell.y,
- erase: event.button === 2,
- filled: event.shiftKey
- };
- shapePreview = { ...shapeGesture };
- drawEditor();
- return true;
- }
- if (paintTool === 'select') {
- const inside = editorSelection && pointInSelection(cell.x, cell.y, editorSelection);
- if (inside) {
- beginEditorGesture();
- const baseSelection = { ...editorSelection };
- selectionGesture = {
- mode: 'move',
- startX: cell.x,
- startY: cell.y,
- lastDx: 0,
- lastDy: 0,
- baseSelection,
- basePixels: getDisplayEditorPixels(),
- baseDepthPixels: getDisplayDepthPixels(),
- baseLightPixels: lightPixels.map((p) => ({ ...p }))
- };
- } else {
- selectionGesture = { mode: 'select', startX: cell.x, startY: cell.y, endX: cell.x, endY: cell.y };
- editorSelection = normalizeSelectionRect(cell.x, cell.y, cell.x, cell.y);
- updateEditorSelectionButtons();
- drawEditor();
- }
- return true;
- }
- return false;
- }
-
- function handleEditorSpecialPointerMove(event) {
- const cell = clientToCell(event.clientX, event.clientY);
- if (!cell) return;
- if (shapeGesture) {
- shapeGesture.endX = cell.x;
- shapeGesture.endY = cell.y;
- shapeGesture.filled = event.shiftKey;
- shapePreview = { ...shapeGesture };
- drawEditor();
- return;
- }
- if (selectionGesture?.mode === 'select') {
- selectionGesture.endX = cell.x;
- selectionGesture.endY = cell.y;
- editorSelection = normalizeSelectionRect(selectionGesture.startX, selectionGesture.startY, cell.x, cell.y);
- updateEditorSelectionButtons();
- drawEditor();
- return;
- }
- if (selectionGesture?.mode === 'move') {
- let dx = cell.x - selectionGesture.startX;
- let dy = cell.y - selectionGesture.startY;
- ({ dx, dy } = clampSelectionDelta(selectionGesture.baseSelection, dx, dy));
- if (dx === selectionGesture.lastDx && dy === selectionGesture.lastDy) return;
- selectionGesture.lastDx = dx;
- selectionGesture.lastDy = dy;
- applySelectionMoveFromBase(selectionGesture.basePixels, selectionGesture.baseDepthPixels, selectionGesture.baseLightPixels, selectionGesture.baseSelection, dx, dy);
- markEditorChanged();
- drawEditor();
- }
- }
-
- function handleEditorSpecialPointerUp(event) {
- if (shapeGesture) {
- const changed = commitShapeGesture(shapeGesture);
- if (changed) markEditorChanged();
- shapeGesture = null;
- shapePreview = null;
- drawEditor();
- return;
- }
- if (selectionGesture?.mode === 'select') {
- const rect = normalizeSelectionRect(selectionGesture.startX, selectionGesture.startY, selectionGesture.endX, selectionGesture.endY);
- editorSelection = rect && rect.w * rect.h > 0 ? rect : null;
- selectionGesture = null;
- drawEditor();
- updateEditorSelectionButtons();
- return;
- }
- if (selectionGesture?.mode === 'move') {
- selectionGesture = null;
- editorLeftPixels = mirrorPixels(editorPixels, editorSize);
- updateEditorSelectionButtons();
- return;
- }
- }
-
- function clientToCell(clientX, clientY) {
- const local = clientToPaintLocal(clientX, clientY);
- const x = Math.floor(local.x);
- const y = Math.floor(local.y);
- if (x < 0 || y < 0 || x >= editorSize || y >= editorSize) return null;
- return { x, y };
- }
-
- function normalizeSelectionRect(x0, y0, x1, y1) {
- const left = clamp(Math.min(x0, x1), 0, editorSize - 1);
- const top = clamp(Math.min(y0, y1), 0, editorSize - 1);
- const right = clamp(Math.max(x0, x1), 0, editorSize - 1);
- const bottom = clamp(Math.max(y0, y1), 0, editorSize - 1);
- return { x: left, y: top, w: right - left + 1, h: bottom - top + 1 };
- }
-
- function pointInSelection(x, y, selection) {
- return selection && x >= selection.x && y >= selection.y && x < selection.x + selection.w && y < selection.y + selection.h;
- }
-
- function clampSelectionDelta(selection, dx, dy) {
- if (!selection) return { dx: 0, dy: 0 };
- return {
- dx: clamp(dx, -selection.x, editorSize - (selection.x + selection.w)),
- dy: clamp(dy, -selection.y, editorSize - (selection.y + selection.h))
- };
- }
-
- function getDisplayDepthPixels() {
- const source = normalizeDepthPixels(depthPixels, editorSize);
- return editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic'
- ? mirrorScalarPixels(source, editorSize)
- : source;
- }
-
- function setDisplayEditorPixels(pixels) {
- editorPixels = editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic'
- ? mirrorPixels(pixels, editorSize)
- : normalizePixels(pixels, editorSize);
- editorLeftPixels = mirrorPixels(editorPixels, editorSize);
- }
-
- function setDisplayDepthPixels(pixels) {
- depthPixels = editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic'
- ? mirrorScalarPixels(pixels, editorSize)
- : normalizeDepthPixels(pixels, editorSize);
- }
-
- function mirrorScalarPixels(values, size) {
- const source = Array.isArray(values) ? values : [];
- const out = Array(size * size).fill(0);
- for (let y = 0; y < size; y++) {
- for (let x = 0; x < size; x++) {
- out[y * size + (size - 1 - x)] = source[y * size + x] || 0;
- }
- }
- return out;
- }
-
- function applySelectionMoveFromBase(basePixels, baseDepthPixels, baseLightPixels, selection, dx, dy) {
- const nextPixels = normalizePixels(basePixels, editorSize);
- const nextDepth = normalizeDepthPixels(baseDepthPixels, editorSize);
- const movedPixels = [...nextPixels];
- const movedDepth = [...nextDepth];
- for (let y = selection.y; y < selection.y + selection.h; y++) {
- for (let x = selection.x; x < selection.x + selection.w; x++) {
- const index = y * editorSize + x;
- movedPixels[index] = null;
- movedDepth[index] = 0;
- }
- }
- for (let y = selection.y; y < selection.y + selection.h; y++) {
- for (let x = selection.x; x < selection.x + selection.w; x++) {
- const from = y * editorSize + x;
- const to = (y + dy) * editorSize + (x + dx);
- movedPixels[to] = nextPixels[from] || null;
- movedDepth[to] = nextDepth[from] || 0;
- }
- }
- setDisplayEditorPixels(movedPixels);
- setDisplayDepthPixels(movedDepth);
- lightPixels = moveDisplayLights(baseLightPixels, selection, dx, dy);
- editorSelection = { x: selection.x + dx, y: selection.y + dy, w: selection.w, h: selection.h };
- updateEditorSelectionButtons();
- }
-
- function moveDisplayLights(sourceLights, selection, dx, dy) {
- const out = [];
- for (const light of sourceLights || []) {
- const shown = canonicalCellToDisplay(light.x, light.y);
- if (!pointInSelection(shown.x, shown.y, selection)) {
- out.push({ ...light });
- continue;
- }
- const nx = shown.x + dx;
- const ny = shown.y + dy;
- if (nx < 0 || ny < 0 || nx >= editorSize || ny >= editorSize) continue;
- const canonical = displayCellToCanonical(nx, ny);
- out.push({ ...light, x: canonical.x, y: canonical.y });
- }
- return out;
- }
-
- function moveSelectionBy(dx, dy) {
- if (!editorSelection) return false;
- const bounded = clampSelectionDelta(editorSelection, dx, dy);
- if (!bounded.dx && !bounded.dy) return false;
- return commitEditorMutation(() => {
- applySelectionMoveFromBase(getDisplayEditorPixels(), getDisplayDepthPixels(), lightPixels.map((p) => ({ ...p })), editorSelection, bounded.dx, bounded.dy);
- return true;
- });
- }
-
- function nudgeEditor(dx, dy) {
- if (editorSelection) moveSelectionBy(dx, dy);
- else shiftEditorContent(dx, dy);
- }
-
- function shiftEditorContent(dx, dy) {
- return commitEditorMutation(() => {
- const selection = { x: 0, y: 0, w: editorSize, h: editorSize };
- const bounded = clampSelectionDelta(selection, dx, dy);
- if (!bounded.dx && !bounded.dy) return false;
- applySelectionMoveFromBase(getDisplayEditorPixels(), getDisplayDepthPixels(), lightPixels.map((p) => ({ ...p })), selection, bounded.dx, bounded.dy);
- editorSelection = null;
- doorPixel = { x: clamp(doorPixel.x + bounded.dx, 0, editorSize - 1), y: clamp(doorPixel.y + bounded.dy, 0, editorSize - 1) };
- return true;
- });
- }
-
- function commitShapeGesture(gesture) {
- const cells = gesture.tool === 'line'
- ? getLineCells(gesture.startX, gesture.startY, gesture.endX, gesture.endY)
- : getRectCells(gesture.startX, gesture.startY, gesture.endX, gesture.endY, gesture.filled);
- if (!cells.length) return false;
- const pixels = getDisplayEditorPixels();
- const value = gesture.erase ? null : selectedColorCode;
- let changed = false;
- for (const cell of cells) {
- const index = cell.y * editorSize + cell.x;
- if ((pixels[index] || null) !== value) {
- pixels[index] = value;
- changed = true;
- }
- if (gesture.erase) {
- const canonical = displayCellToCanonical(cell.x, cell.y);
- removeLightPixel(canonical.x, canonical.y);
- }
- }
- if (!changed) return false;
- setDisplayEditorPixels(pixels);
- return true;
- }
-
- function getLineCells(x0, y0, x1, y1) {
- const cells = [];
- let dx = Math.abs(x1 - x0);
- let dy = -Math.abs(y1 - y0);
- const sx = x0 < x1 ? 1 : -1;
- const sy = y0 < y1 ? 1 : -1;
- let err = dx + dy;
- let x = x0;
- let y = y0;
- while (true) {
- cells.push({ x, y });
- if (x === x1 && y === y1) break;
- const e2 = 2 * err;
- if (e2 >= dy) { err += dy; x += sx; }
- if (e2 <= dx) { err += dx; y += sy; }
- }
- return cells.filter((cell) => cell.x >= 0 && cell.y >= 0 && cell.x < editorSize && cell.y < editorSize);
- }
-
- function getRectCells(x0, y0, x1, y1, filled = false) {
- const rect = normalizeSelectionRect(x0, y0, x1, y1);
- const cells = [];
- for (let y = rect.y; y < rect.y + rect.h; y++) {
- for (let x = rect.x; x < rect.x + rect.w; x++) {
- if (filled || x === rect.x || y === rect.y || x === rect.x + rect.w - 1 || y === rect.y + rect.h - 1) cells.push({ x, y });
- }
- }
- return cells;
- }
-
- function drawEditorOverlays(cell) {
- if (shapePreview) {
- const cells = shapePreview.tool === 'line'
- ? getLineCells(shapePreview.startX, shapePreview.startY, shapePreview.endX, shapePreview.endY)
- : getRectCells(shapePreview.startX, shapePreview.startY, shapePreview.endX, shapePreview.endY, shapePreview.filled);
- pctx.save();
- pctx.globalAlpha = shapePreview.erase ? 0.32 : 0.42;
- pctx.fillStyle = shapePreview.erase ? '#ff6b6b' : colorToHex(selectedColorCode);
- for (const c of cells) pctx.fillRect(c.x * cell, c.y * cell, Math.ceil(cell), Math.ceil(cell));
- pctx.restore();
- }
- if (editorSelection) {
- pctx.save();
- pctx.strokeStyle = '#ff4fa3';
- pctx.lineWidth = Math.max(2, 2 / editorView.zoom);
- pctx.setLineDash([Math.max(3, 6 / editorView.zoom), Math.max(3, 4 / editorView.zoom)]);
- pctx.strokeRect(editorSelection.x * cell + 1 / editorView.zoom, editorSelection.y * cell + 1 / editorView.zoom, editorSelection.w * cell - 2 / editorView.zoom, editorSelection.h * cell - 2 / editorView.zoom);
- pctx.fillStyle = 'rgba(255, 79, 163, .08)';
- pctx.fillRect(editorSelection.x * cell, editorSelection.y * cell, editorSelection.w * cell, editorSelection.h * cell);
- pctx.restore();
- }
- }
-
- function updateEditorSelectionButtons() {
- const disabled = !editorSelection;
- if (els.clearSelection) els.clearSelection.disabled = disabled;
- }
-
- function flipEditorHorizontal() {
- commitEditorMutation(() => {
- editorPixels = mirrorPixels(editorPixels, editorSize);
- depthPixels = mirrorScalarPixels(depthPixels, editorSize);
- lightPixels = lightPixels.map((p) => ({ ...p, x: editorSize - 1 - p.x }));
- doorPixel = { ...doorPixel, x: editorSize - 1 - doorPixel.x };
- editorSelection = editorSelection ? { x: editorSize - (editorSelection.x + editorSelection.w), y: editorSelection.y, w: editorSelection.w, h: editorSelection.h } : null;
- return true;
- });
- }
-
- function flipEditorVertical() {
- commitEditorMutation(() => {
- editorPixels = flipPixelsVertical(editorPixels, editorSize);
- depthPixels = flipScalarPixelsVertical(depthPixels, editorSize);
- lightPixels = lightPixels.map((p) => ({ ...p, y: editorSize - 1 - p.y }));
- doorPixel = { ...doorPixel, y: editorSize - 1 - doorPixel.y };
- editorSelection = editorSelection ? { x: editorSelection.x, y: editorSize - (editorSelection.y + editorSelection.h), w: editorSelection.w, h: editorSelection.h } : null;
- return true;
- });
- }
-
- function flipPixelsVertical(pixels, size) {
- const source = normalizePixels(pixels, size);
- const out = blankPixels(size);
- for (let y = 0; y < size; y++) {
- for (let x = 0; x < size; x++) out[(size - 1 - y) * size + x] = source[y * size + x] || null;
- }
- return out;
- }
-
- function flipScalarPixelsVertical(values, size) {
- const source = normalizeDepthPixels(values, size);
- const out = Array(size * size).fill(0);
- for (let y = 0; y < size; y++) {
- for (let x = 0; x < size; x++) out[(size - 1 - y) * size + x] = source[y * size + x] || 0;
- }
- return out;
- }
-
- function applyEditorOutline() {
- commitEditorMutation(() => {
- const source = [...editorPixels];
- const out = [...editorPixels];
- let changed = false;
- for (let y = 0; y < editorSize; y++) {
- for (let x = 0; x < editorSize; x++) {
- const index = y * editorSize + x;
- if (source[index]) continue;
- const adjacent = [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => {
- const nx = x + dx;
- const ny = y + dy;
- return nx >= 0 && ny >= 0 && nx < editorSize && ny < editorSize && source[ny * editorSize + nx];
- });
- if (adjacent) { out[index] = selectedColorCode; changed = true; }
- }
- }
- if (!changed) return false;
- editorPixels = out;
- return true;
- });
- }
-
- function exportEditorPng() {
- const scale = Math.max(8, Math.floor(512 / editorSize));
- const canvas = document.createElement('canvas');
- canvas.width = editorSize * scale;
- canvas.height = editorSize * scale;
- const c = canvas.getContext('2d');
- c.imageSmoothingEnabled = false;
- const pixels = getDisplayEditorPixels();
- for (let y = 0; y < editorSize; y++) {
- for (let x = 0; x < editorSize; x++) {
- const code = pixels[y * editorSize + x];
- if (!code) continue;
- c.fillStyle = colorToHex(code);
- c.fillRect(x * scale, y * scale, scale, scale);
- }
- }
- const link = document.createElement('a');
- const rawName = (els.assetName.value || 'pixel-art').trim().replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'pixel-art';
- link.download = `${rawName}-${editorSize}x${editorSize}.png`;
- link.href = canvas.toDataURL('image/png');
- link.click();
- toast('PNG exported.');
- }
-
- function importEditorPng(event) {
- const file = event.target.files?.[0];
- event.target.value = '';
- if (!file) return;
- const img = new Image();
- img.onload = () => {
- commitEditorMutation(() => {
- const canvas = document.createElement('canvas');
- canvas.width = editorSize;
- canvas.height = editorSize;
- const c = canvas.getContext('2d', { willReadFrequently: true });
- c.imageSmoothingEnabled = false;
- c.clearRect(0, 0, editorSize, editorSize);
- c.drawImage(img, 0, 0, editorSize, editorSize);
- const data = c.getImageData(0, 0, editorSize, editorSize).data;
- const next = blankPixels(editorSize);
- for (let i = 0; i < editorSize * editorSize; i++) {
- const alpha = data[i * 4 + 3];
- if (alpha < 32) continue;
- const hex = rgbToHex(data[i * 4], data[i * 4 + 1], data[i * 4 + 2]);
- next[i] = nearestPaletteCode(hex);
- }
- setDisplayEditorPixels(next);
- editorSelection = null;
- return true;
- });
- URL.revokeObjectURL(img.src);
- toast(`PNG imported into ${editorSize}×${editorSize}.`);
- };
- img.onerror = () => toast('Could not read that PNG.');
- img.src = URL.createObjectURL(file);
- }
-
- function rgbToHex(r, g, b) {
- const part = (value) => clamp(Math.round(value), 0, 255).toString(16).padStart(2, '0');
- return `#${part(r)}${part(g)}${part(b)}`;
- }
-
- function selectPaletteCode(code) {
- if (!PALETTE_BY_CODE[code]) return;
- selectedColorCode = code;
- els.paintColor.value = PALETTE_BY_CODE[code];
- renderPalette();
- }
-
- function getActiveEditorPixels() {
- return editorPixels;
- }
-
- function getDisplayEditorPixels() {
- return editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic'
- ? mirrorPixels(editorPixels, editorSize)
- : normalizePixels(editorPixels, editorSize);
- }
-
- function displayCellToCanonical(x, y) {
- if (editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic') return { x: editorSize - 1 - x, y };
- return { x, y };
- }
-
- function canonicalCellToDisplay(x, y) {
- if (editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic') return { x: editorSize - 1 - x, y };
- return { x, y };
- }
-
- function setupEditor(size, rightPixels, leftPixels = null, nextDepthPixels = null) {
- editorSize = size;
- const normalizedRight = normalizePixels(rightPixels, size);
- const normalizedLeft = leftPixels ? normalizePixels(leftPixels, size) : null;
- const canonical = hasAnyPixel(normalizedRight) || !normalizedLeft ? normalizedRight : mirrorPixels(normalizedLeft, size);
- editorPixels = normalizePixels(canonical, size);
- editorLeftPixels = mirrorPixels(editorPixels, size);
- editorSelection = null;
- depthPixels = Array.isArray(nextDepthPixels) || typeof nextDepthPixels === 'string' ? normalizeDepthPixels(nextDepthPixels, size) : resizeDepthPixels(depthPixels, Math.sqrt(depthPixels.length) || size, size);
- els.assetSize.value = String(size);
- lightPixels = lightPixels.filter((p) => p.x >= 0 && p.y >= 0 && p.x < size && p.y < size);
- doorPixel = { x: clamp(doorPixel.x, 0, size - 1), y: clamp(doorPixel.y, 0, size - 1) };
- resetEditorView();
- drawEditor();
- updateEditorHistoryButtons();
- }
-
- function drawEditor() {
- clampEditorView();
- const canvas = els.paintCanvas;
- const rectSize = canvas.width;
- const cell = rectSize / editorSize;
- pctx.clearRect(0, 0, rectSize, rectSize);
- pctx.fillStyle = '#fffaf0';
- pctx.fillRect(0, 0, rectSize, rectSize);
-
- pctx.save();
- pctx.translate(editorView.x, editorView.y);
- pctx.scale(editorView.zoom, editorView.zoom);
-
- const pixels = getDisplayEditorPixels();
- for (let y = 0; y < editorSize; y++) {
- for (let x = 0; x < editorSize; x++) {
- const color = pixels[y * editorSize + x];
- if (!color) continue;
- pctx.fillStyle = colorToHex(color);
- pctx.fillRect(x * cell, y * cell, Math.ceil(cell), Math.ceil(cell));
- }
- }
-
- if (advancedDraw && depthPixels?.length) {
- if (paintTool === 'depth') {
- pctx.fillStyle = 'rgba(46, 89, 160, .07)';
- pctx.fillRect(0, 0, rectSize, rectSize);
- }
- for (let y = 0; y < editorSize; y++) {
- for (let x = 0; x < editorSize; x++) {
- const source = displayCellToCanonical(x, y);
- const depth = depthPixels[source.y * editorSize + source.x] || 0;
- if (!depth) continue;
- pctx.fillStyle = 'rgba(34, 116, 255, .62)';
- pctx.fillRect(x * cell + Math.max(1, cell * .12), y * cell + Math.max(1, cell * .12), Math.max(2, cell * .76), Math.max(2, cell * .76));
- pctx.strokeStyle = 'rgba(6, 28, 75, .55)';
- pctx.lineWidth = Math.max(1, 2 / editorView.zoom);
- pctx.strokeRect(x * cell + Math.max(1, cell * .12), y * cell + Math.max(1, cell * .12), Math.max(2, cell * .76), Math.max(2, cell * .76));
- }
- }
- }
-
- pctx.strokeStyle = 'rgba(36, 48, 68, .13)';
- pctx.lineWidth = 1 / editorView.zoom;
- for (let i = 0; i <= editorSize; i++) {
- const p = Math.round(i * cell) + .5;
- pctx.beginPath(); pctx.moveTo(p, 0); pctx.lineTo(p, rectSize); pctx.stroke();
- pctx.beginPath(); pctx.moveTo(0, p); pctx.lineTo(rectSize, p); pctx.stroke();
- }
-
- {
- for (const light of lightPixels) {
- const shown = canonicalCellToDisplay(light.x, light.y);
- const lx = (shown.x + .5) * cell;
- const ly = (shown.y + .5) * cell;
- pctx.fillStyle = hexToRgba(colorToHex(light.c || selectedColorCode), .28);
- pctx.beginPath();
- pctx.arc(lx, ly, Math.max(4, cell * .20), 0, Math.PI * 2);
- pctx.fill();
- pctx.strokeStyle = colorToHex(light.c || selectedColorCode);
- pctx.lineWidth = 2 / editorView.zoom;
- pctx.beginPath();
- pctx.arc(lx, ly, Math.max(3, cell * .14), 0, Math.PI * 2);
- pctx.stroke();
- }
- if (currentRole() === 'building') {
- pctx.strokeStyle = '#5fb8ff';
- pctx.lineWidth = 3 / editorView.zoom;
- pctx.strokeRect(doorPixel.x * cell + 2 / editorView.zoom, doorPixel.y * cell + 2 / editorView.zoom, cell - 4 / editorView.zoom, cell - 4 / editorView.zoom);
- }
- }
- drawEditorOverlays(cell);
- pctx.restore();
- }
-
- function resetEditorView() {
- editorView.zoom = 1;
- editorView.x = 0;
- editorView.y = 0;
- clampEditorView();
- }
-
- function clampEditorView() {
- const size = els.paintCanvas.width;
- const scaled = size * editorView.zoom;
- const minOffset = size - scaled;
- editorView.x = clamp(editorView.x, minOffset, 0);
- editorView.y = clamp(editorView.y, minOffset, 0);
- }
-
- function addLightPixel(x, y, colorCode = selectedColorCode) {
- const point = { x: clamp(Math.floor(Number(x)), 0, editorSize - 1), y: clamp(Math.floor(Number(y)), 0, editorSize - 1), c: colorCode };
- const existing = lightPixels.find((p) => p.x === point.x && p.y === point.y);
- if (existing) existing.c = colorCode;
- else lightPixels.push(point);
- }
-
- function removeLightPixel(x, y) {
- lightPixels = lightPixels.filter((p) => !(p.x === x && p.y === y));
- }
-
-
- function alignEditorStateToBottom(size) {
- const shift = getBottomShift(editorPixels, size);
- return {
- rightPixels: shiftPixelsVertical(editorPixels, size, shift),
- depthPixels: shiftDepthPixelsVertical(depthPixels, size, shift),
- lightPixels: lightPixels
- .map((p) => ({ ...p, y: p.y + shift }))
- .filter((p) => p.x >= 0 && p.x < size && p.y >= 0 && p.y < size),
- door: { x: doorPixel.x, y: clamp(doorPixel.y + shift, 0, size - 1) }
- };
- }
-
- function getBottomShift(pixels, size) {
- let maxY = -1;
- for (let y = 0; y < size; y++) {
- for (let x = 0; x < size; x++) {
- if (pixels[y * size + x]) maxY = Math.max(maxY, y);
- }
- }
- return maxY < 0 ? 0 : size - 1 - maxY;
- }
-
- function shiftPixelsVertical(pixels, size, shift) {
- if (!shift) return normalizePixels(pixels, size);
- const out = blankPixels(size);
- const source = normalizePixels(pixels, size);
- for (let y = 0; y < size; y++) {
- for (let x = 0; x < size; x++) {
- const ny = y + shift;
- if (ny >= 0 && ny < size) out[ny * size + x] = source[y * size + x] || null;
- }
- }
- return out;
- }
-
- function shiftDepthPixelsVertical(pixels, size, shift) {
- const source = normalizeDepthPixels(pixels, size);
- if (!shift) return source;
- const out = Array(size * size).fill(0);
- for (let y = 0; y < size; y++) {
- for (let x = 0; x < size; x++) {
- const ny = y + shift;
- if (ny >= 0 && ny < size) out[ny * size + x] = source[y * size + x] || 0;
- }
- }
- return out;
- }
-
- function saveAssetFromEditor() {
- const role = currentRole();
- const category = roleToCategory(role);
- const subtype = roleToSubtype(role);
- const size = editorSize;
- const existing = editingAssetId ? findAsset(editingAssetId) : null;
- const paintedDots = countPixels(editorPixels);
- if (paintedDots < 10) {
- toast('Draw at least 10 pixels before saving.');
- return null;
- }
- const name = (els.assetName.value || '').trim() || existing?.name || `${cap(role)} ${state.assets.length + 1}`;
- const aligned = alignEditorStateToBottom(size);
- const asset = {
- id: existing?.id || uid(),
- name,
- category,
- subtype,
- size,
- createdAt: existing?.createdAt || Date.now(),
- updatedAt: Date.now(),
- author: existing?.author || state.authorName || 'Local Artist',
- parentAssetId: existing ? existing.parentAssetId : editParentId,
- originalAssetId: existing ? existing.originalAssetId : editOriginalId,
- pixels: encodePixels(aligned.rightPixels),
- faces: category === 'dynamic' ? {
- right: encodePixels(aligned.rightPixels),
- left: 'mirror'
- } : null,
- meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, size)
- };
- asset.contentHash = computeAssetContentHash(asset);
- if (existing) {
- const index = state.assets.findIndex((a) => a.id === existing.id);
- if (index >= 0) state.assets[index] = asset;
- toast(`${asset.name} updated.`);
- } else {
- state.assets.unshift(asset);
- toast(`${asset.name} saved.`);
- }
- selectedAssetId = asset.id;
- recordSyncEvent(Phase2Sync?.createAssetUpsertEvent?.(asset));
- editParentId = null;
- editOriginalId = null;
- editingAssetId = null;
- editingAssetId = asset.id;
- saveState();
- spriteCache.clear();
- hydrateRuntime();
- renderLibrary();
- updateSelectedLabel();
- els.lineageNote.textContent = existing ? 'Updated the original asset.' : 'Saved as a new asset.';
- return asset;
- }
-
- function saveAndPlaceFromEditor() {
- const asset = saveAssetFromEditor();
- if (!asset) return;
- selectedAssetId = asset.id;
- setMode('place');
- setDrawerOpen(false);
- toast('Saved. Click an island tile to summon it.');
- }
-
- function newAsset() {
- editParentId = null;
- editOriginalId = null;
- els.assetName.value = '';
- els.assetCategory.value = 'nature';
- staticKind = 'nature';
- dynamicKind = 'human';
- editingSide = 'right';
- lightPixels = [];
- depthPixels = blankPixels(8).map(() => 0);
- doorPixel = { x: Math.floor(editorSize / 2), y: editorSize - 1 };
- setupEditor(8, blankPixels(8), null);
- clearEditorHistory();
- refreshCategoryUI();
- els.lineageNote.textContent = '';
- }
-
-
- function hydrateAuthorUI() {
- state.authorName = state.authorName || 'Local Artist';
- if (els.authorName) els.authorName.value = state.authorName;
- }
-
- function focusAssetInWorld(asset) {
- const placed = state.placed.find((p) => p.assetId === asset.id);
- const dyn = state.dynamicSummons.find((p) => p.assetId === asset.id);
- const target = placed ? { x: placed.x + .5, y: placed.y + .5 } : dyn ? { x: dyn.homeX + .5, y: dyn.homeY + .5 } : null;
- if (!target) {
- selectedObject = null;
- setMode('place');
- toast('Selected. Place it on the map.');
- return;
- }
- const pos = tileToWorld(target.x, target.y);
- pos.y -= getLiftAtCoord(target.x, target.y);
- view.x = cw / 2 - pos.x * view.zoom;
- view.y = ch / 2 - pos.y * view.zoom;
- if (placed) selectWorldObject('static', placed.id, asset.id, performance.now());
- else if (dyn) selectWorldObject('dynamic', dyn.id, asset.id, performance.now());
- setMode('inspect');
- }
-
- function renderLibrary() {
- els.assetList.innerHTML = '';
- const visibleAssets = state.assets.filter((asset) => !state.hiddenAssets?.[asset.id]);
- if (!visibleAssets.length) {
- els.assetList.textContent = 'No visible assets.';
- renderHiddenAssets();
- return;
- }
- const myName = (state.authorName || 'Local Artist').trim() || 'Local Artist';
- const matchesFilter = (asset) => libraryFilter === 'all' || subtypeToRole(asset) === libraryFilter;
- const mine = visibleAssets.filter((asset) => (asset.author || 'Local Artist') === myName && matchesFilter(asset));
- const others = visibleAssets.filter((asset) => (asset.author || 'Local Artist') !== myName && matchesFilter(asset));
-
- addLibrarySection('My works', mine);
- addLibrarySection('Others', others);
- renderHiddenAssets();
- }
-
- function addLibrarySection(title, assets) {
- const heading = document.createElement('button');
- heading.type = 'button';
- heading.className = 'librarySectionTitle';
- heading.textContent = `${title} · ${libraryFilterLabel()}`;
- heading.title = 'Click to filter by genre';
- heading.addEventListener('click', () => {
- cycleLibraryFilter();
- renderLibrary();
- });
- els.assetList.append(heading);
- if (!assets.length) {
- const empty = document.createElement('div');
- empty.className = 'libraryEmptyNote';
- empty.textContent = 'No assets in this genre.';
- els.assetList.append(empty);
- return;
- }
- const grid = document.createElement('div');
- grid.className = 'assetSectionGrid';
- for (const asset of assets) grid.append(makeAssetCard(asset));
- els.assetList.append(grid);
- }
-
- function libraryFilterLabel() {
- return libraryFilter === 'all' ? 'All' : cap(libraryFilter);
- }
-
- function cycleLibraryFilter() {
- const filters = ['all', 'human', 'animal', 'nature', 'building', 'other'];
- const index = filters.indexOf(libraryFilter);
- libraryFilter = filters[(index + 1) % filters.length];
- toast(`Library filter: ${libraryFilterLabel()}`);
- }
-
- function makeAssetCard(asset) {
- const card = document.createElement('article');
- card.className = `assetCard${asset.id === selectedAssetId ? ' selected' : ''}`;
-
- const preview = document.createElement('canvas');
- preview.className = 'assetPreview';
- preview.width = 64;
- preview.height = 64;
- drawPreview(preview, asset);
-
- const meta = document.createElement('div');
- meta.className = 'assetMeta';
- const lineage = asset.parentAssetId ? ' / derivative' : '';
- meta.innerHTML = `${displayCategory(asset)} / ${cap(asset.subtype)} / ${asset.size}×${asset.size}${lineage}`;
- meta.querySelector('strong').textContent = asset.name;
- meta.querySelectorAll('span')[1].textContent = `Author: ${asset.author || 'Local Artist'} · Remixed: ${getRemixCount(asset.id)}`;
-
- card.addEventListener('click', () => {
- selectedAssetId = asset.id;
- updateSelectedLabel();
- renderLibrary();
- focusAssetInWorld(asset);
- });
-
- const actions = document.createElement('div');
- actions.className = 'assetActions';
- const assetVotes = getAssetVoteCounts(asset.id);
- const assetPreviousVote = assetVotes.voters?.[currentVoterKey()] || 0;
- const up = makeButton(`▲ ${assetVotes.up}`, (event) => { event.stopPropagation(); voteAsset(asset.id, 1); });
- const down = makeButton(`▼ ${assetVotes.down}`, (event) => { event.stopPropagation(); voteAsset(asset.id, -1); });
- up.classList.toggle('activeVote', assetPreviousVote > 0);
- down.classList.toggle('activeVote', assetPreviousVote < 0);
- up.classList.toggle('mutedVote', assetPreviousVote < 0);
- down.classList.toggle('mutedVote', assetPreviousVote > 0);
- const hide = makeButton('Hide', (event) => { event.stopPropagation(); hideAsset(asset.id); });
- hide.classList.toggle('mutedAction', assetPreviousVote >= 0);
- const copy = makeButton(((asset.author || 'Local Artist') === (state.authorName || 'Local Artist')) ? 'Edit' : 'Copy Edit', (event) => {
- event?.stopPropagation?.();
- copyEdit(asset);
- });
- const del = makeButton('Delete', (event) => {
- event?.stopPropagation?.();
- deleteAsset(asset);
- });
- del.classList.add('danger');
- actions.append(up, down, hide, copy, del);
- meta.append(actions);
- card.append(preview, meta);
- return card;
- }
-
- function renderHiddenAssets() {
- if (!els.hiddenAssetList) return;
- els.hiddenAssetList.innerHTML = '';
- const hidden = state.assets.filter((asset) => state.hiddenAssets?.[asset.id]);
- if (!hidden.length) {
- els.hiddenAssetList.textContent = 'No hidden assets.';
- return;
- }
- for (const asset of hidden) {
- const row = document.createElement('article');
- row.className = 'assetCard hiddenAssetCard';
- const preview = document.createElement('canvas');
- preview.className = 'assetPreview';
- preview.width = 64;
- preview.height = 64;
- drawPreview(preview, asset);
- const meta = document.createElement('div');
- meta.className = 'assetMeta';
- meta.innerHTML = `Author: ${asset.author || 'Local Artist'}`;
- meta.querySelector('strong').textContent = asset.name;
- const actions = document.createElement('div');
- actions.className = 'assetActions';
- const restore = makeButton('Show again', (event) => {
- event.stopPropagation();
- delete state.hiddenAssets[asset.id];
- saveState();
- renderLibrary();
- toast('Asset shown again.');
- });
- actions.append(restore);
- meta.append(actions);
- row.append(preview, meta);
- els.hiddenAssetList.append(row);
- }
- }
-
- function drawPreview(canvas, asset) {
- const c = canvas.getContext('2d');
- c.imageSmoothingEnabled = false;
- c.clearRect(0, 0, canvas.width, canvas.height);
- c.fillStyle = '#fff3d9';
- c.fillRect(0, 0, canvas.width, canvas.height);
- const pixels = getAssetPixels(asset, 'right');
- const scale = Math.floor(48 / asset.size) || 1;
- const ox = Math.floor((canvas.width - asset.size * scale) / 2);
- const oy = Math.floor((canvas.height - asset.size * scale) / 2);
- for (let y = 0; y < asset.size; y++) {
- for (let x = 0; x < asset.size; x++) {
- const color = pixels[y * asset.size + x];
- if (!color) continue;
- c.fillStyle = colorToHex(color);
- c.fillRect(ox + x * scale, oy + y * scale, scale, scale);
- }
- }
- }
-
- function renderPalette() {
- if (!els.paletteGrid) return;
- els.paletteGrid.innerHTML = '';
- for (const entry of PALETTE) {
- const swatch = document.createElement('button');
- swatch.type = 'button';
- swatch.className = `paletteSwatch${entry.code === selectedColorCode ? ' active' : ''}`;
- swatch.dataset.code = entry.code;
- swatch.title = `${entry.code} ${entry.color}`;
- swatch.style.background = entry.color;
- swatch.addEventListener('click', () => {
- selectPaletteCode(entry.code);
- drawEditor();
- });
- els.paletteGrid.append(swatch);
- }
- els.paintColor.value = PALETTE_BY_CODE[selectedColorCode] || '#6bd06b';
- }
-
- function displayCategory(asset) {
- return asset.category === 'dynamic' ? 'People & Animals' : 'Buildings & Nature';
- }
-
- function makeButton(label, onClick) {
- const button = document.createElement('button');
- button.type = 'button';
- button.textContent = label;
- button.addEventListener('click', onClick);
- return button;
- }
-
- function copyEdit(asset) {
- setDrawerOpen(true);
- setTab('draw');
- const mine = (asset.author || 'Local Artist') === (state.authorName || 'Local Artist');
- editingAssetId = mine ? asset.id : null;
- editParentId = mine ? asset.parentAssetId : asset.id;
- editOriginalId = mine ? asset.originalAssetId : (asset.originalAssetId || asset.id);
- els.assetName.value = mine ? asset.name : `${asset.name} Remix`;
- els.assetCategory.value = subtypeToRole(asset);
- staticKind = asset.category === 'static' ? asset.subtype : staticKind;
- dynamicKind = asset.category === 'dynamic' ? asset.subtype : dynamicKind;
- editingSide = 'right';
- const right = getAssetPixels(asset, 'right');
- lightPixels = (asset.meta?.lightPixels || []).map((p) => ({ x: p.x, y: p.y, c: p.c || asset.meta?.lightColor || selectedColorCode }));
- depthPixels = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size);
- doorPixel = asset.meta?.door || { x: Math.floor(asset.size / 2), y: asset.size - 1 };
- setupEditor(asset.size, right, null, depthPixels);
- clearEditorHistory();
- refreshCategoryUI();
- els.lineageNote.textContent = mine ? `Editing original “${asset.name}”.` : `Editing a derivative of “${asset.name}”. Save creates a new asset.`;
- }
-
- function deleteAsset(asset) {
- const used = state.placed.some((p) => p.assetId === asset.id) || state.dynamicSummons.some((p) => p.assetId === asset.id);
- if (used && !confirm(`Delete “${asset.name}”? It is used in the world and will be removed there too.`)) return;
- state.assets = state.assets.filter((a) => a.id !== asset.id);
- state.placed = state.placed.filter((p) => p.assetId !== asset.id);
- state.dynamicSummons = state.dynamicSummons.filter((p) => p.assetId !== asset.id);
- if (selectedAssetId === asset.id) selectedAssetId = state.assets[0]?.id ?? null;
- saveState();
- spriteCache.clear();
- hydrateRuntime();
- renderLibrary();
- updateSelectedLabel();
- }
-
- function updateSelectedLabel() {
- if (!els.selectedAssetName) return;
- const asset = findAsset(selectedAssetId);
- els.selectedAssetName.textContent = asset ? `Selected: ${asset.name} (${displayCategory(asset)} / ${cap(asset.subtype)})` : 'Selected: none';
- }
-
- function hydrateRuntime() {
- dynamicRuntime = state.dynamicSummons.map((summon) => {
- const asset = findAsset(summon.assetId);
- if (!asset) return null;
- const homeTile = world.get(Math.round(summon.homeX), Math.round(summon.homeY));
- const homeValid = homeTile && ((asset.subtype === 'fish') ? homeTile.type === 'water' : homeTile.type !== 'water');
- const spawn = homeValid ? { x: Math.round(summon.homeX), y: Math.round(summon.homeY) } : findValidSpawn(asset, summon.homeX, summon.homeY, 6);
- return {
- id: summon.id,
- assetId: summon.assetId,
- homeX: summon.homeX,
- homeY: summon.homeY,
- x: spawn.x + .5,
- y: spawn.y + .5,
- targetX: spawn.x + .5,
- targetY: spawn.y + .5,
- vx: 1,
- hiddenUntil: 0,
- seed: Math.random() * 9999,
- nextDecisionAt: 0,
- nextBubbleAt: 800 + Math.random() * 1500
- };
- }).filter(Boolean);
- }
-
- function findValidSpawn(asset, homeX, homeY, radius) {
- if (asset.subtype === 'bird') {
- const x = clamp(Math.round(homeX), 0, WORLD_W - 1);
- const y = clamp(Math.round(homeY), 0, WORLD_H - 1);
- if (world.get(x, y)?.type !== 'water') return { x, y };
- }
- const candidates = [];
- for (let dy = -radius; dy <= radius; dy++) {
- for (let dx = -radius; dx <= radius; dx++) {
- const x = Math.round(homeX + dx);
- const y = Math.round(homeY + dy);
- if (x < 0 || y < 0 || x >= WORLD_W || y >= WORLD_H) continue;
- const tile = world.get(x, y);
- if (asset.subtype === 'fish') {
- if (tile.type === 'water') candidates.push({ x, y });
- } else if (tile.type !== 'water') candidates.push({ x, y });
- }
- }
- if (!candidates.length) return { x: clamp(Math.round(homeX), 0, WORLD_W - 1), y: clamp(Math.round(homeY), 0, WORLD_H - 1) };
- return candidates[Math.floor(Math.random() * candidates.length)];
- }
-
- function updateDynamicRuntime(dt, time) {
- bubbleParticles = bubbleParticles.filter((p) => time - p.started < p.life);
- confettiParticles = confettiParticles.filter((p) => time - p.started < p.life);
- for (const item of dynamicRuntime) {
- const asset = findAsset(item.assetId);
- if (!asset) continue;
- if (time < item.hiddenUntil) continue;
- if (asset.subtype === 'fish' && time > (item.nextBubbleAt || 0)) {
- spawnFishBubbleCluster(item.x, item.y, time, item.seed);
- item.nextBubbleAt = time + 1100 + Math.random() * 1800;
- }
- const distance = Math.hypot(item.targetX - item.x, item.targetY - item.y);
- if (distance < .15 || time > item.nextDecisionAt) chooseTarget(item, asset, time);
-
- const speed = ({ human: .77, animal: .67, car: .37, fish: .47, bird: .75 }[asset.subtype] || .53) * dt;
- const dx = item.targetX - item.x;
- const dy = item.targetY - item.y;
- const len = Math.hypot(dx, dy) || 1;
- item.vx = dx === 0 ? item.vx : Math.sign(dx);
- item.x += (dx / len) * speed;
- item.y += (dy / len) * speed;
-
- if (asset.subtype === 'human' && distance < .3 && Math.random() < .004) {
- item.hiddenUntil = time + 1700 + Math.random() * 2200;
- }
- }
- }
-
- function chooseTarget(item, asset, time) {
- let target = null;
- if (asset.subtype === 'human') target = findNearestPlaced(item.x, item.y, 'building', 14);
- else if (asset.subtype === 'animal' || asset.subtype === 'bird') target = findNearestPlaced(item.x, item.y, 'nature', 16);
-
- if (target && Math.random() < .72) {
- item.targetX = target.x + .5 + (Math.random() - .5) * 1.8;
- item.targetY = target.y + .5 + (Math.random() - .5) * 1.8;
- } else {
- const spawn = findValidSpawn(asset, item.homeX, item.homeY, asset.subtype === 'bird' ? 2 : 7);
- item.targetX = spawn.x + .5;
- item.targetY = spawn.y + .5;
- }
- item.nextDecisionAt = time + 1600 + Math.random() * 2800;
- }
-
- function findNearestPlaced(x, y, subtype, maxDistance) {
- let best = null;
- let bestDistance = maxDistance;
- const minX = clampInt(Math.floor(x - maxDistance), 0, WORLD_W - 1, 0);
- const maxX = clampInt(Math.ceil(x + maxDistance), 0, WORLD_W - 1, WORLD_W - 1);
- const minY = clampInt(Math.floor(y - maxDistance), 0, WORLD_H - 1, 0);
- const maxY = clampInt(Math.ceil(y + maxDistance), 0, WORLD_H - 1, WORLD_H - 1);
- for (let ty = minY; ty <= maxY; ty++) {
- for (let tx = minX; tx <= maxX; tx++) {
- const bucket = worldIndex.placedByTile.get(tileKey(tx, ty));
- if (!bucket) continue;
- for (const placed of bucket) {
- const asset = findAsset(placed.assetId);
- if (!asset || asset.subtype !== subtype) continue;
- const distance = Math.hypot(placed.x + .5 - x, placed.y + .5 - y);
- if (distance < bestDistance) {
- bestDistance = distance;
- best = placed;
- }
- }
- }
- }
- return best;
- }
-
-
- function scheduleFrame() {
- if (animationFrameId || document.hidden) return;
- animationFrameId = requestAnimationFrame(tick);
- }
-
- function onVisibilityChange() {
- if (document.hidden) {
- if (animationFrameId) cancelAnimationFrame(animationFrameId);
- animationFrameId = 0;
- lastRuntimeUpdate = performance.now();
- dynamicLogicRemainder = 0;
- return;
- }
- lastRuntimeUpdate = performance.now();
- dynamicLogicRemainder = 0;
- render(lastRuntimeUpdate);
- scheduleFrame();
- }
-
- function tick(time) {
- animationFrameId = 0;
- if (document.hidden) {
- lastRuntimeUpdate = time;
- return;
- }
- const elapsed = Math.min(100, Math.max(0, time - lastRuntimeUpdate));
- lastRuntimeUpdate = time;
- dynamicLogicRemainder += elapsed;
- let steps = 0;
- while (dynamicLogicRemainder >= DYNAMIC_LOGIC_STEP_MS && steps < MAX_DYNAMIC_STEPS_PER_FRAME) {
- updateDynamicRuntime(DYNAMIC_LOGIC_STEP_MS / 1000, time);
- dynamicLogicRemainder -= DYNAMIC_LOGIC_STEP_MS;
- steps++;
- }
- if (steps >= MAX_DYNAMIC_STEPS_PER_FRAME) dynamicLogicRemainder = 0;
- updateClock();
- render(time);
- scheduleFrame();
- }
-
- function updateClock() {
- const phase = getPhase();
- const second = Math.floor(Date.now() / 1000);
- if (second === lastClockSecond) return;
- lastClockSecond = second;
- els.phaseLabel.textContent = phase.label;
- els.phaseBar.style.width = `${Math.round(phase.progress * 100)}%`;
- }
-
- function getShadowForMinute(minute) {
- const isNight = minute >= 6;
- const local = isNight ? (minute - 6) / 4 : minute / 6;
- const eased = clamp(local, 0, 1);
- const elevation = Math.sin(eased * Math.PI);
- // Morning: down-left, Noon: straight down, Evening: down-right.
- // The top edge of the shadow is always anchored to the sprite's bottom edge.
- const dirX = lerp(-1.0, 1.0, eased);
- const length = lerp(1.35, 0.58, elevation);
- return {
- dirX,
- length,
- skewX: dirX * (0.72 + 0.32 * length),
- scaleY: 0.24 + 0.06 * length,
- alpha: isNight ? .12 : lerp(.28, .16, elevation)
- };
- }
-
- function getPhase() {
- const t = mod(Date.now(), DAY_MS);
- const minute = t / 60000;
- const stops = [
- { at: 0, key: 'morning', label: 'Morning', darkness: 0.18, tint: [255, 208, 144, 0.12] },
- { at: 1, key: 'day', label: 'Day', darkness: 0.00, tint: [255, 255, 255, 0.00] },
- { at: 5, key: 'day', label: 'Day', darkness: 0.00, tint: [255, 255, 255, 0.00] },
- { at: 6, key: 'evening', label: 'Evening', darkness: 0.18, tint: [255, 146, 114, 0.14] },
- { at: 10, key: 'night', label: 'Night', darkness: 0.48, tint: [36, 47, 96, 0.18] }
- ];
- let a = stops[0], b = stops[1];
- for (let i = 0; i < stops.length - 1; i++) {
- if (minute >= stops[i].at && minute < stops[i + 1].at) { a = stops[i]; b = stops[i + 1]; break; }
- if (minute >= 6) { a = stops[3]; b = stops[4]; }
- }
- const localT = clamp((minute - a.at) / Math.max(0.0001, b.at - a.at), 0, 1);
- const eased = localT * localT * (3 - 2 * localT);
- const tint = a.tint.map((v, i) => lerp(v, b.tint[i], eased));
- const label = minute < 1 ? 'Morning' : minute < 5 ? 'Day' : minute < 6 ? 'Evening' : 'Night';
- return {
- key: label.toLowerCase(),
- label,
- progress: t / DAY_MS,
- darkness: lerp(a.darkness, b.darkness, eased),
- tint: `rgba(${Math.round(tint[0])}, ${Math.round(tint[1])}, ${Math.round(tint[2])}, ${tint[3].toFixed(3)})`,
- shadow: getShadowForMinute(minute)
- };
- }
-
- function render(time = performance.now()) {
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
- ctx.clearRect(0, 0, cw, ch);
- ctx.fillStyle = '#86d5ff';
- ctx.fillRect(0, 0, cw, ch);
- const phase = getPhase();
- renderPhase = phase;
-
- ctx.save();
- ctx.translate(view.x, view.y);
- ctx.scale(view.zoom, view.zoom);
- ctx.imageSmoothingEnabled = false;
- drawTerrainCache(terrainCache);
- drawHoverTile();
- const lightSources = [];
- drawObjects(time, lightSources, phase);
- drawBubbleParticles(time);
- drawSpawnEffects(time);
- drawConfettiParticles(time);
- ctx.restore();
-
- updateSelectionBubble(time);
- if (phase.tint !== 'rgba(255, 255, 255, 0)') {
- ctx.fillStyle = phase.tint;
- ctx.fillRect(0, 0, cw, ch);
- }
- if (phase.darkness > 0) {
- ctx.fillStyle = `rgba(12, 19, 45, ${phase.darkness})`;
- ctx.fillRect(0, 0, cw, ch);
- drawLightSources(lightSources, phase.darkness);
- }
- }
-
- function drawHoverTile() {
- if (!hoverTile) return;
- const { x, y } = tileToWorld(hoverTile.x, hoverTile.y);
- const lift = getTileLift(hoverTile.tile);
- ctx.beginPath();
- ctx.moveTo(x, y - lift);
- ctx.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift);
- ctx.lineTo(x, y + TILE_H - lift);
- ctx.lineTo(x - TILE_W / 2, y + TILE_H / 2 - lift);
- ctx.closePath();
- ctx.strokeStyle = 'rgba(255, 255, 255, .7)';
- ctx.lineWidth = 1 / view.zoom;
- ctx.stroke();
- }
-
- function drawSpawnEffects(time) {
- if (!spawnEffects.length) return;
- spawnEffects = spawnEffects.filter((effect) => time - effect.started < 650);
- for (const effect of spawnEffects) {
- const t = clamp((time - effect.started) / 650, 0, 1);
- const pos = tileToWorld(effect.x, effect.y);
- pos.y -= getLiftAtCoord(effect.x, effect.y);
- const radius = 6 + t * 24;
- ctx.save();
- ctx.globalAlpha = 1 - t;
- ctx.lineWidth = Math.max(1, 3 / view.zoom);
- ctx.strokeStyle = '#ff79ad';
- ctx.beginPath();
- ctx.arc(pos.x, pos.y + TILE_H * .35, radius, 0, Math.PI * 2);
- ctx.stroke();
- ctx.fillStyle = '#fff2a8';
- for (let i = 0; i < 5; i++) {
- const a = i * Math.PI * .4 + t * 2.4;
- ctx.fillRect(pos.x + Math.cos(a) * radius - 2, pos.y + TILE_H * .35 + Math.sin(a) * radius - 2, 4, 4);
- }
- ctx.restore();
- }
- }
-
- function drawObjects(time, lightSources, phase) {
- const items = getDrawableItems(time, getViewportWorldRect(VIEW_CULL_MARGIN));
-
- for (const item of items) {
- if (item.asset.category === 'dynamic' && item.asset.subtype === 'fish') {
- drawSpriteItem(item, time, lightSources, true, phase);
- }
- }
- for (const item of items) {
- if (!(item.asset.category === 'dynamic' && item.asset.subtype === 'fish')) {
- drawSpriteItem(item, time, lightSources, false, phase);
- }
- }
- }
-
- function getSpriteDrawInfo(item, time, includeSelectBounce = true) {
- const asset = item.asset;
- const pos = tileToWorld(item.x, item.y);
- const applyTileLift = !(asset.category === 'dynamic' && asset.subtype === 'bird');
- if (applyTileLift) pos.y -= getLiftAtCoord(item.x, item.y);
- let bob = 0;
- if (includeSelectBounce && asset.category === 'static' && selectedObject?.id === item.source?.id) {
- const selectedT = clamp((time - (selectedObject.selectedAt || 0)) / 520, 0, 1);
- if (selectedT < 1) bob += -Math.sin(selectedT * Math.PI) * 8;
- }
- let alpha = 1;
- let side = 'right';
- let angle = 0;
- if (asset.category === 'static' && asset.subtype === 'water') bob += Math.sin(time / 900 + item.x * .7) * 1.6;
- if (asset.category === 'dynamic') {
- const runtime = item.source;
- side = runtime.vx < 0 ? 'left' : 'right';
- if (asset.subtype === 'human' || asset.subtype === 'animal') {
- const jumpPhase = time / 78 + runtime.seed * 0.9;
- const hop = Math.pow(Math.abs(Math.sin(jumpPhase)), 1.15);
- const liftNow = getLiftAtCoord(item.x, item.y);
- const liftTarget = getLiftAtCoord(runtime.targetX, runtime.targetY);
- const terrainBoost = Math.abs(liftTarget - liftNow) / 12;
- bob = -hop * (2.2 + terrainBoost * 1.8 + (liftNow > 0 ? 0.6 : 0));
- const cycle = Math.floor(jumpPhase / (Math.PI * 0.9));
- const rand = pseudoNoise(cycle + runtime.seed * 13.17);
- angle = (rand - 0.5) * 0.24 * hop;
- }
- if (asset.subtype === 'bird') bob = -10 - Math.sin(time / 250 + runtime.seed) * 4.5;
- if (asset.subtype === 'fish') { alpha = .42; bob = 4 + Math.sin(time / 380 + runtime.seed) * 1.7; }
- }
- const sprite = getSpriteCanvas(asset, side);
- const drawX = pos.x - sprite.width / 2;
- const drawY = pos.y + TILE_H / 2 - sprite.height + bob;
- return { asset, pos, sprite, drawX, drawY, alpha, angle, side, bob };
- }
-
- function drawSpriteItem(item, time, lightSources, underwater, phase) {
- const { asset, pos, sprite, drawX, drawY, alpha, angle, side } = getSpriteDrawInfo(item, time, true);
-
- if (!(asset.category === 'dynamic' && asset.subtype === 'fish')) drawSpriteShadow(drawX, drawY, sprite, phase);
-
- ctx.save();
- ctx.globalAlpha = alpha;
- if (angle) {
- ctx.translate(Math.round(drawX + sprite.width / 2), Math.round(drawY + sprite.height * 0.8));
- ctx.rotate(angle);
- ctx.drawImage(sprite, Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8));
- } else {
- ctx.drawImage(sprite, Math.round(drawX), Math.round(drawY));
- }
- if (underwater) {
- ctx.fillStyle = 'rgba(103, 181, 217, .18)';
- if (angle) ctx.fillRect(Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8), sprite.width, sprite.height);
- else ctx.fillRect(Math.round(drawX), Math.round(drawY), sprite.width, sprite.height);
- }
- ctx.restore();
-
- if (asset.meta?.hasLight && asset.meta.lightPixels?.length) {
- const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE;
- for (const light of asset.meta.lightPixels) {
- const lx = asset.category === 'dynamic' && side === 'left' ? asset.size - 1 - light.x : light.x;
- const wx = drawX + (lx + 0.5) * scale;
- const wy = drawY + (light.y + 0.5) * scale;
- lightSources.push({ x: wx, y: wy, color: colorToHex(light.c || asset.meta.lightColor || nearestPaletteCode('#ffd86a')), radius: lerp(12, 20, asset.size / 64) });
- }
- }
- }
-
- function drawFishBubbles(x, y, time, seed) {
- const cycle = Math.floor((time + seed * 37) / 1300);
- if (pseudoNoise(cycle + seed) < 0.55) return;
- const t = ((time + seed * 37) % 1300) / 1300;
- ctx.save();
- ctx.fillStyle = 'rgba(225, 252, 255, .58)';
- ctx.globalAlpha = Math.max(0, 1 - t);
- for (let i = 0; i < 4; i++) {
- const ox = Math.round((pseudoNoise(cycle + seed + i * 4.1) - .5) * 7);
- const oy = Math.round(-t * 12 - i * 3);
- ctx.fillRect(Math.round(x + ox), Math.round(y + oy), 1, 1);
- }
- ctx.restore();
- }
-
-
-
- function drawSpriteShadow(drawX, drawY, sprite, phase) {
- const shadow = phase?.shadow || { dirX: 0, length: 1, skewX: 0, scaleY: 0.28, alpha: .16 };
- const silhouette = getShadowCanvas(sprite);
- const contactX = drawX + sprite.width / 2;
- const contactY = drawY + sprite.height;
-
- ctx.save();
- ctx.globalAlpha = shadow.alpha;
- // The silhouette is vertically flipped in getShadowCanvas().
- // Drawing it at y=0 means the flipped top edge, which was the sprite's bottom edge,
- // exactly touches the sprite's bottom/contact line.
- ctx.translate(contactX, contactY);
- ctx.transform(1 + shadow.length * 0.08, 0, shadow.skewX, shadow.scaleY, 0, 0);
- ctx.drawImage(silhouette, Math.round(-sprite.width / 2), 0);
- ctx.restore();
- }
-
- function getShadowCanvas(sprite) {
- if (shadowCanvasCache.has(sprite)) return shadowCanvasCache.get(sprite);
- const canvas = document.createElement('canvas');
- canvas.width = sprite.width;
- canvas.height = sprite.height;
- const c = canvas.getContext('2d');
- c.imageSmoothingEnabled = false;
- c.save();
- c.translate(0, sprite.height);
- c.scale(1, -1);
- c.drawImage(sprite, 0, 0);
- c.restore();
- c.globalCompositeOperation = 'source-in';
- c.fillStyle = '#1b1f26';
- c.fillRect(0, 0, canvas.width, canvas.height);
- shadowCanvasCache.set(sprite, canvas);
- return canvas;
- }
-
-
- function currentVoterKey() {
- return ((state.authorName || els.authorName?.value || 'Local Artist').trim() || 'Local Artist').toLowerCase();
- }
-
- function getObjectVoteCounts(objectId) {
- state.objectVotes ||= {};
- const votes = state.objectVotes[objectId] || { up: 0, down: 0, voters: {} };
- votes.voters ||= {};
- return votes;
- }
-
- function getAssetVoteCounts(assetId) {
- state.assetVotes ||= {};
- const votes = state.assetVotes[assetId] || { up: 0, down: 0, voters: {} };
- votes.voters ||= {};
- return votes;
- }
-
- function voteAsset(assetId, delta) {
- state.assetVotes ||= {};
- const votes = state.assetVotes[assetId] || { up: 0, down: 0, voters: {} };
- votes.voters ||= {};
- const voter = currentVoterKey();
- const previous = votes.voters[voter] || 0;
- if (previous > 0) votes.up = Math.max(0, votes.up - 1);
- if (previous < 0) votes.down = Math.max(0, votes.down - 1);
- if (previous === delta) delete votes.voters[voter];
- else {
- if (delta > 0) votes.up += 1;
- else votes.down += 1;
- votes.voters[voter] = delta;
- }
- state.assetVotes[assetId] = votes;
- saveState();
- renderLibrary();
- }
-
- function hideAsset(assetId) {
- state.hiddenAssets ||= {};
- state.hiddenAssets[assetId] = true;
- if (selectedAssetId === assetId) selectedAssetId = state.assets.find((a) => !state.hiddenAssets?.[a.id])?.id || null;
- if (selectedObject?.assetId === assetId) selectedObject = null;
- saveState();
- renderLibrary();
- updateSelectionBubble(performance.now());
- toast('Asset hidden locally.');
- }
-
- function getRemixCount(assetId) {
- return state.assets.filter((asset) => asset.parentAssetId === assetId || asset.originalAssetId === assetId).length;
- }
-
- function countPixels(pixels) {
- return Array.isArray(pixels) ? pixels.filter(Boolean).length : normalizePixels(pixels || [], editorSize).filter(Boolean).length;
- }
-
- function getSelectedWorldPosition() {
- if (!selectedObject) return null;
- const asset = findAsset(selectedObject.assetId);
- if (!asset) return null;
- if (selectedObject.kind === 'static') {
- const placed = state.placed.find((p) => p.id === selectedObject.id);
- if (!placed) return null;
- const pos = tileToWorld(placed.x + .5, placed.y + .5);
- pos.y -= getLiftAtCoord(placed.x + .5, placed.y + .5);
- return { pos, asset, objectId: placed.id };
- }
- const runtime = dynamicRuntime.find((r) => r.id === selectedObject.id);
- const summon = state.dynamicSummons.find((s) => s.id === selectedObject.id);
- const source = runtime || (summon ? { x: summon.homeX + .5, y: summon.homeY + .5 } : null);
- if (!source) return null;
- const pos = tileToWorld(source.x, source.y);
- if (asset.subtype !== 'bird') pos.y -= getLiftAtCoord(source.x, source.y);
- return { pos, asset, objectId: selectedObject.id };
- }
-
- function updateSelectionBubble(time) {
- if (!els.selectionBubble) return;
- const selected = getSelectedWorldPosition();
- if (!selected) {
- els.selectionBubble.hidden = true;
- return;
- }
- const { pos, asset, objectId } = selected;
- const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE;
- const topOffset = Math.max(18, asset.size * scale + 26);
- const sx = pos.x * view.zoom + view.x;
- const sy = (pos.y - topOffset) * view.zoom + view.y;
- if (sx < -100 || sy < -100 || sx > cw + 100 || sy > ch + 100) {
- els.selectionBubble.hidden = true;
- return;
- }
- els.selectionBubble.hidden = false;
- els.selectionBubble.style.transform = `translate(${Math.round(sx)}px, ${Math.round(sy)}px) translate(-50%, -100%)`;
- els.bubbleName.textContent = asset.name || 'Untitled';
- els.bubbleAuthor.textContent = `by ${asset.author || 'Local Artist'}`;
- const parent = asset.parentAssetId ? findAsset(asset.parentAssetId) : null;
- if (els.bubbleRemixFrom) {
- els.bubbleRemixFrom.hidden = !parent;
- els.bubbleRemixFrom.textContent = parent ? `Remix of: ${parent.name || 'Untitled'}` : '';
- }
- if (els.bubbleRemixCount) els.bubbleRemixCount.textContent = `Remixed: ${getRemixCount(asset.id)}`;
- const votes = getObjectVoteCounts(objectId);
- const voter = currentVoterKey();
- const previous = votes.voters?.[voter] || 0;
- els.voteScore.textContent = `${votes.up - votes.down}`;
- els.voteUp.classList.toggle('activeVote', previous > 0);
- els.voteDown.classList.toggle('activeVote', previous < 0);
- els.voteUp.classList.toggle('mutedVote', previous < 0);
- els.voteDown.classList.toggle('mutedVote', previous > 0);
- if (els.bubbleHide) els.bubbleHide.hidden = previous >= 0;
- }
-
- function voteSelected(delta) {
- if (!selectedObject) return;
- state.objectVotes ||= {};
- const key = selectedObject.id;
- const votes = state.objectVotes[key] || { up: 0, down: 0, voters: {} };
- votes.voters ||= {};
- const voter = currentVoterKey();
- const previous = votes.voters[voter] || 0;
- if (previous > 0) votes.up = Math.max(0, votes.up - 1);
- if (previous < 0) votes.down = Math.max(0, votes.down - 1);
- if (previous === delta) {
- delete votes.voters[voter];
- } else {
- if (delta > 0) {
- votes.up += 1;
- spawnConfettiAtSelection(performance.now());
- } else {
- votes.down += 1;
- }
- votes.voters[voter] = delta;
- }
- state.objectVotes[key] = votes;
- saveState();
- updateSelectionBubble(performance.now());
- renderLibrary();
- // Server moderation TODO: when online, hide or delete objects from the server if downvotes exceed a threshold
- // such as down >= 5 and down - up >= 3. Do not implement server deletion in this local prototype.
- }
-
- function remixSelected() {
- if (!selectedObject) return;
- const asset = findAsset(selectedObject.assetId);
- if (asset) copyEdit(asset);
- }
-
- function hideSelected() {
- if (!selectedObject) return;
- state.hiddenObjects ||= {};
- state.hiddenObjects[selectedObject.id] = true;
- const assetId = selectedObject.assetId;
- selectedObject = null;
- saveState();
- updateSelectionBubble(performance.now());
- renderLibrary();
- toast('Hidden locally.');
- // Server moderation TODO: when synced, repeated low-rating + hide actions can flag this object for review.
- }
-
- function spawnFishBubbleCluster(tileX, tileY, time, seed = 0) {
- const count = 2 + Math.floor(pseudoNoise(time * 0.003 + seed) * 3);
- for (let i = 0; i < count; i++) {
- bubbleParticles.push({
- x: tileX + (pseudoNoise(seed + time * .01 + i * 7.1) - .5) * .22,
- y: tileY + (pseudoNoise(seed + time * .013 + i * 5.7) - .5) * .22,
- started: time + i * 45,
- life: 900 + pseudoNoise(seed + i * 3.3) * 550,
- drift: (pseudoNoise(seed + i * 9.9) - .5) * .08
- });
- }
- }
-
- function drawBubbleParticles(time) {
- if (!bubbleParticles.length) return;
- ctx.save();
- ctx.fillStyle = 'rgba(225, 252, 255, .62)';
- for (const particle of bubbleParticles) {
- const age = (time - particle.started) / particle.life;
- if (age < 0 || age > 1) continue;
- const pos = tileToWorld(particle.x + particle.drift * age, particle.y);
- pos.y -= 3 + age * 18;
- ctx.globalAlpha = 1 - age;
- ctx.fillRect(Math.round(pos.x), Math.round(pos.y), 1, 1);
- }
- ctx.restore();
- }
-
- function spawnConfettiAtSelection(time) {
- const selected = getSelectedWorldPosition();
- if (!selected) return;
- const palette = ['a', 'f', 'l', 's', 'A', 'H', 'P', 'Z'];
- for (let i = 0; i < 22; i++) {
- const angle = (Math.PI * 2 * i) / 22 + pseudoNoise(time + i) * .4;
- const speed = 18 + pseudoNoise(i * 4.7 + time) * 34;
- confettiParticles.push({
- x: selected.pos.x,
- y: selected.pos.y - selected.asset.size * (selected.asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE) - 8,
- vx: Math.cos(angle) * speed,
- vy: Math.sin(angle) * speed - 25,
- color: palette[i % palette.length],
- started: time,
- life: 850 + pseudoNoise(i + time) * 450
- });
- }
- }
-
- function drawConfettiParticles(time) {
- if (!confettiParticles.length) return;
- ctx.save();
- for (const particle of confettiParticles) {
- const ageMs = time - particle.started;
- const t = ageMs / particle.life;
- if (t < 0 || t > 1) continue;
- const seconds = ageMs / 1000;
- const x = particle.x + particle.vx * seconds;
- const y = particle.y + particle.vy * seconds + 48 * seconds * seconds;
- ctx.globalAlpha = 1 - t;
- ctx.fillStyle = colorToHex(particle.color);
- ctx.fillRect(Math.round(x), Math.round(y), 3, 3);
- }
- ctx.restore();
- }
-
- function drawLightSources(sources, darkness) {
- ctx.save();
- ctx.globalCompositeOperation = 'lighter';
- for (const source of sources) {
- const sx = source.x * view.zoom + view.x;
- const sy = source.y * view.zoom + view.y;
- const radius = source.radius * view.zoom;
- if (sx < -radius || sy < -radius || sx > cw + radius || sy > ch + radius) continue;
- const gradient = ctx.createRadialGradient(sx, sy, 0, sx, sy, radius);
- gradient.addColorStop(0, hexToRgba(source.color, .18 + darkness * .10));
- gradient.addColorStop(.35, hexToRgba(source.color, .08));
- gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
- ctx.fillStyle = gradient;
- ctx.beginPath();
- ctx.arc(sx, sy, radius, 0, Math.PI * 2);
- ctx.fill();
- }
- ctx.restore();
- }
-
- function getSpriteCanvas(asset, side) {
- const key = `${asset.id}:${side}:${asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE}`;
- if (spriteCache.has(key)) return spriteCache.get(key);
- const pixels = getAssetPixels(asset, side);
- const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE;
- const canvas = document.createElement('canvas');
- canvas.width = asset.size * scale;
- canvas.height = asset.size * scale;
- const c = canvas.getContext('2d');
- c.imageSmoothingEnabled = false;
- for (let y = 0; y < asset.size; y++) {
- for (let x = 0; x < asset.size; x++) {
- const color = pixels[y * asset.size + x];
- if (!color) continue;
- c.fillStyle = applyDepthToColor(colorToHex(color), getAssetDepth(asset, x, y, side));
- c.fillRect(x * scale, y * scale, scale, scale);
- }
- }
- spriteCache.set(key, canvas);
- return canvas;
- }
-
- function getAssetPixels(asset, side = 'right') {
- const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(asset.size), asset.size);
- if (asset.category === 'dynamic' && side === 'left') return mirrorPixels(right, asset.size);
- return right;
- }
-
- function makeWorld() {
- const islands = [
- { cx: 36, cy: 34, rx: 32, ry: 22, height: 1.04 },
- { cx: 92, cy: 30, rx: 34, ry: 20, height: .98 },
- { cx: 74, cy: 76, rx: 42, ry: 24, height: 1.02 },
- { cx: 124, cy: 72, rx: 16, ry: 14, height: .92 },
- { cx: 26, cy: 86, rx: 16, ry: 12, height: .9 },
- { cx: 118, cy: 102, rx: 20, ry: 9, height: .86 }
- ];
- const tiles = [];
- for (let y = 0; y < WORLD_H; y++) {
- for (let x = 0; x < WORLD_W; x++) {
- const continent = fractalPerlin(x * .035, y * .035, 4);
- const detail = fractalPerlin(x * .095 + 41.7, y * .095 - 13.2, 3);
- let score = -1.2;
- for (const island of islands) {
- const warpX = (fractalPerlin(x * .055 + island.cx, y * .055, 2) - .5) * 7.0;
- const warpY = (fractalPerlin(x * .055, y * .055 + island.cy, 2) - .5) * 5.0;
- const dx = (x + warpX - island.cx) / island.rx;
- const dy = (y + warpY - island.cy) / island.ry;
- const base = 1 - (dx * dx + dy * dy);
- score = Math.max(score, base * island.height);
- }
- score += (continent - .5) * .36 + (detail - .5) * .18;
- let type = 'water';
- if (score > .015) type = 'sand';
- if (score > .17) type = 'grass';
- if (score > .56 && fractalPerlin(x * .16 + 8, y * .16 + 4, 2) > .56) type = 'highland';
- const heightLevel = type === 'highland' ? 1 : 0;
- tiles.push({ x, y, type, heightLevel, shade: fractalPerlin(x * .31 + 9, y * .31 - 2, 2) - .5 });
- }
- }
- return {
- tiles,
- get(x, y) {
- if (x < 0 || y < 0 || x >= WORLD_W || y >= WORLD_H) return null;
- return tiles[y * WORLD_W + x];
- }
- };
- }
-
- function buildTerrainCache(worldData) {
- const chunkSize = TERRAIN_CHUNK_SIZE;
- const chunks = [];
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
- for (let cy = 0; cy < WORLD_H; cy += chunkSize) {
- for (let cx = 0; cx < WORLD_W; cx += chunkSize) {
- const tiles = [];
- for (let y = cy; y < Math.min(WORLD_H, cy + chunkSize); y++) {
- for (let x = cx; x < Math.min(WORLD_W, cx + chunkSize); x++) {
- const tile = worldData.get(x, y);
- if (tile) tiles.push(tile);
- }
- }
- if (!tiles.length) continue;
- const bounds = getTerrainChunkBounds(tiles);
- const canvas = document.createElement('canvas');
- canvas.width = Math.ceil(bounds.w);
- canvas.height = Math.ceil(bounds.h);
- const c = canvas.getContext('2d', { alpha: true });
- c.imageSmoothingEnabled = false;
- c.save();
- c.translate(-bounds.x, -bounds.y);
- for (const tile of tiles) drawTerrainTile(c, tile, worldData);
- c.restore();
- chunks.push({ canvas, x: bounds.x, y: bounds.y, w: canvas.width, h: canvas.height, tileX: cx, tileY: cy });
- minX = Math.min(minX, bounds.x);
- minY = Math.min(minY, bounds.y);
- maxX = Math.max(maxX, bounds.x + canvas.width);
- maxY = Math.max(maxY, bounds.y + canvas.height);
- }
- }
- if (!chunks.length) return { chunks: [], bounds: { x: 0, y: 0, w: 1, h: 1 }, chunkSize };
- return { chunks, bounds: { x: minX, y: minY, w: maxX - minX, h: maxY - minY }, chunkSize };
- }
-
- function getTerrainChunkBounds(tiles) {
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
- for (const tile of tiles) {
- const p = tileToWorld(tile.x, tile.y);
- const lift = getTileLift(tile);
- minX = Math.min(minX, p.x - TILE_W / 2 - 3);
- maxX = Math.max(maxX, p.x + TILE_W / 2 + 3);
- minY = Math.min(minY, p.y - lift - 16);
- maxY = Math.max(maxY, p.y + TILE_H + 18);
- }
- minX = Math.floor(minX);
- minY = Math.floor(minY);
- maxX = Math.ceil(maxX);
- maxY = Math.ceil(maxY);
- return { x: minX, y: minY, w: Math.max(1, maxX - minX), h: Math.max(1, maxY - minY) };
- }
-
- function drawTerrainCache(cache) {
- const rect = getViewportWorldRect(64);
- for (const chunk of cache.chunks) {
- if (chunk.x + chunk.w < rect.left || chunk.x > rect.right || chunk.y + chunk.h < rect.top || chunk.y > rect.bottom) continue;
- ctx.drawImage(chunk.canvas, chunk.x, chunk.y);
- }
- }
-
-
- function drawTerrainTile(c, tile, worldData) {
- const { x, y } = tileToWorld(tile.x, tile.y);
- const palette = {
- water: ['#6ebfe0', '#69bbdc'],
- sand: ['#ead99d', '#e8d293'],
- grass: ['#91cf76', '#89c970'],
- highland: ['#9db781', '#95ab79']
- };
- const [base, alt] = palette[tile.type];
- const fill = tile.shade > 0 ? base : alt;
- const lift = getTileLift(tile);
-
- if (tile.type === 'highland') {
- const frontLeft = worldData.get(tile.x, tile.y + 1);
- const frontRight = worldData.get(tile.x + 1, tile.y);
- const leftLower = !frontLeft || frontLeft.type !== 'highland';
- const rightLower = !frontRight || frontRight.type !== 'highland';
- if (rightLower) {
- c.fillStyle = '#7b8e6c';
- c.beginPath();
- c.moveTo(x, y + TILE_H - lift);
- c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift);
- c.lineTo(x + TILE_W / 2, y + TILE_H / 2);
- c.lineTo(x, y + TILE_H);
- c.closePath();
- c.fill();
- }
- if (leftLower) {
- c.fillStyle = '#6f8263';
- c.beginPath();
- c.moveTo(x - TILE_W / 2, y + TILE_H / 2 - lift);
- c.lineTo(x, y + TILE_H - lift);
- c.lineTo(x, y + TILE_H);
- c.lineTo(x - TILE_W / 2, y + TILE_H / 2);
- c.closePath();
- c.fill();
- }
- }
-
- c.fillStyle = fill;
- c.beginPath();
- c.moveTo(x, y - lift);
- c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift);
- c.lineTo(x, y + TILE_H - lift);
- c.lineTo(x - TILE_W / 2, y + TILE_H / 2 - lift);
- c.closePath();
- c.fill();
-
- if (tile.type === 'water') {
- c.fillStyle = 'rgba(255,255,255,.045)';
- c.beginPath();
- c.moveTo(x - TILE_W * .20, y + TILE_H * .48);
- c.lineTo(x + TILE_W * .15, y + TILE_H * .31);
- c.lineTo(x + TILE_W * .25, y + TILE_H * .38);
- c.lineTo(x - TILE_W * .10, y + TILE_H * .55);
- c.closePath();
- c.fill();
- }
-
- c.strokeStyle = 'rgba(36, 48, 68, .02)';
- c.lineWidth = 1;
- c.beginPath();
- c.moveTo(x, y - lift);
- c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift);
- c.lineTo(x, y + TILE_H - lift);
- c.lineTo(x - TILE_W / 2, y + TILE_H / 2 - lift);
- c.closePath();
- c.stroke();
- }
-
- function getTileLift(tile) {
- return tile?.type === 'highland' ? 12 : 0;
- }
-
- function getLiftAtCoord(x, y) {
- const tile = world.get(clamp(Math.floor(x), 0, WORLD_W - 1), clamp(Math.floor(y), 0, WORLD_H - 1));
- return getTileLift(tile);
- }
-
- function loadState() {
- try {
- const raw = localStorage.getItem(STORAGE_KEY);
- if (raw) {
- const parsed = JSON.parse(raw);
- const expanded = Phase2Sync?.isCompactState?.(parsed) ? Phase2Sync.expandState(parsed) : parsed;
- if (expanded && Array.isArray(expanded.assets)) return normalizeState(expanded);
- }
- } catch (error) {
- console.warn('Could not load local state.', error);
- }
- return seedState();
- }
-
- function saveState() {
- state.schema = SAVE_SCHEMA;
- state.eventLog = Array.isArray(state.eventLog) ? state.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)) : [];
- const payload = Phase2Sync?.compactState ? Phase2Sync.compactState(state) : state;
- localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
- cachePhase2State();
- updateSyncStats();
- }
-
- function normalizeState(input) {
- return {
- schema: SAVE_SCHEMA,
- authorName: input.authorName || 'Local Artist',
- assets: Array.isArray(input.assets) ? input.assets.map(normalizeAsset) : [],
- placed: normalizePlacements(Array.isArray(input.placed) ? input.placed : []),
- dynamicSummons: normalizeDynamicSummons(Array.isArray(input.dynamicSummons) ? input.dynamicSummons : []),
- objectVotes: input.objectVotes || {},
- assetVotes: input.assetVotes || {},
- hiddenAssets: input.hiddenAssets || {},
- hiddenObjects: input.hiddenObjects || {},
- eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)) : [],
- sync: input.sync || { lastEventId: null }
- };
- }
-
- function normalizeAsset(asset) {
- const size = Number(asset.size) || 16;
- const category = asset.category === 'dynamic' ? 'dynamic' : 'static';
- const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(size), size);
- const subtype = asset.subtype || (category === 'dynamic' ? 'human' : 'other');
- const normalized = {
- id: asset.id || uid(),
- name: asset.name || 'Untitled',
- category,
- subtype,
- size,
- pixels: encodePixels(right),
- faces: category === 'dynamic' ? { right: encodePixels(right), left: 'mirror' } : null,
- parentAssetId: asset.parentAssetId || null,
- originalAssetId: asset.originalAssetId || null,
- createdAt: asset.createdAt || Date.now(),
- updatedAt: asset.updatedAt || asset.createdAt || Date.now(),
- author: asset.author || 'Local Artist',
- meta: normalizeAssetMeta(asset.meta || {}, category, subtype, size)
- };
- normalized.contentHash = computeAssetContentHash(normalized);
- return normalized;
- }
-
- function normalizeAssetMeta(meta, category, subtype, size) {
- const normalizedDepth = normalizeDepthPixels(meta.depthPixels || [], size);
- const lightPixels = Array.isArray(meta.lightPixels)
- ? meta.lightPixels
- .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || meta.lightColor || nearestPaletteCode('#ffd86a') }))
- .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y))
- : [];
- return buildAssetMeta(category, subtype, normalizedDepth, lightPixels, meta.lightColor || '#ffd86a', meta.door || null, size);
- }
-
- function buildAssetMeta(category, subtype, sourceDepthPixels, sourceLightPixels, lightColor, door, size = editorSize) {
- const normalizedDepth = normalizeDepthPixels(sourceDepthPixels || [], size);
- const hasDepth = normalizedDepth.some(Boolean);
- const lightPixelsClean = Array.isArray(sourceLightPixels)
- ? sourceLightPixels
- .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || nearestPaletteCode(lightColor || '#ffd86a') }))
- .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y))
- : [];
- const hasLight = lightPixelsClean.length > 0;
- return {
- hasLight,
- lightPixels: hasLight ? lightPixelsClean : [],
- lightColor: hasLight ? nearestPaletteCode(lightColor || '#ffd86a') : null,
- depthPixels: hasDepth ? encodeDepthPixels(normalizedDepth) : null,
- door: category === 'static' && subtype === 'building' && door ? { x: clampInt(door.x, 0, size - 1, Math.floor(size / 2)), y: clampInt(door.y, 0, size - 1, size - 1) } : null
- };
- }
-
- function normalizePlacements(items) {
- return dedupeByAsset(items).map((item) => ({
- id: item.id || uid(),
- assetId: item.assetId,
- x: clampInt(item.x, 0, WORLD_W - 1, 0),
- y: clampInt(item.y, 0, WORLD_H - 1, 0),
- placedAt: item.placedAt || item.createdAt || Date.now(),
- version: Number(item.version) || 1
- })).filter((item) => item.assetId);
- }
-
- function normalizeDynamicSummons(items) {
- return dedupeByAsset(items).map((item) => ({
- id: item.id || uid(),
- assetId: item.assetId,
- homeX: clampInt(item.homeX ?? item.x, 0, WORLD_W - 1, 0),
- homeY: clampInt(item.homeY ?? item.y, 0, WORLD_H - 1, 0),
- createdAt: item.createdAt || item.placedAt || Date.now(),
- version: Number(item.version) || 1
- })).filter((item) => item.assetId);
- }
-
- function dedupeByAsset(items) {
- const map = new Map();
- for (const item of items) {
- if (item && item.assetId) map.set(item.assetId, item);
- }
- return [...map.values()];
- }
-
- function findNearestTerrain(type, startX, startY) {
- let best = null;
- let bestD = Infinity;
- const maxR = Math.max(WORLD_W, WORLD_H);
- for (let r = 0; r < maxR; r++) {
- for (let y = Math.max(0, startY - r); y <= Math.min(WORLD_H - 1, startY + r); y++) {
- for (let x = Math.max(0, startX - r); x <= Math.min(WORLD_W - 1, startX + r); x++) {
- const tile = world.get(x, y);
- if (!tile || tile.type !== type) continue;
- const d = Math.hypot(x - startX, y - startY);
- if (d < bestD) { bestD = d; best = { x, y }; }
- }
- }
- if (best) return best;
- }
- return { x: startX, y: startY };
- }
-
- function homeFromPos(pos) {
- return { homeX: pos.x, homeY: pos.y };
- }
-
- function seedState() {
- const assets = [
- makeAsset('Cozy House', 'static', 'building', drawHouse(), { hasLight: true, lightPixels: [{ x: 10, y: 8 }, { x: 11, y: 8 }], lightColor: '#ffd86a', door: { x: 7, y: 14 } }),
- makeAsset('Round Tree', 'static', 'nature', drawTree()),
- makeAsset('Dock Lamp', 'static', 'water', drawLantern(), { hasLight: true, lightPixels: [{ x: 7, y: 6 }, { x: 8, y: 6 }], lightColor: '#b8e8ff' }),
- makeAsset('Traveler', 'dynamic', 'human', drawHumanRight(), {}, drawHumanLeft()),
- makeAsset('Island Pup', 'dynamic', 'animal', drawDogRight(), {}, drawDogLeft()),
- makeAsset('Blue Fish', 'dynamic', 'fish', drawFishRight(), {}, drawFishLeft()),
- makeAsset('Tiny Bird', 'dynamic', 'bird', drawBirdRight(), {}, drawBirdLeft())
- ];
-
- const idByName = Object.fromEntries(assets.map((a) => [a.name, a.id]));
- return {
- schema: SAVE_SCHEMA,
- authorName: 'Local Artist',
- assets,
- placed: [
- { id: uid(), assetId: idByName['Cozy House'], x: 36, y: 36, placedAt: Date.now() },
- { id: uid(), assetId: idByName['Round Tree'], x: 32, y: 36, placedAt: Date.now() },
- { id: uid(), assetId: idByName['Dock Lamp'], ...findNearestTerrain('water', 62, 58), placedAt: Date.now() }
- ],
- objectVotes: {},
- assetVotes: {},
- hiddenAssets: {},
- hiddenObjects: {},
- dynamicSummons: [
- { id: uid(), assetId: idByName['Traveler'], homeX: 36, homeY: 38, createdAt: Date.now() },
- { id: uid(), assetId: idByName['Island Pup'], homeX: 32, homeY: 40, createdAt: Date.now() },
- { id: uid(), assetId: idByName['Blue Fish'], ...homeFromPos(findNearestTerrain('water', 63, 60)), createdAt: Date.now() },
- { id: uid(), assetId: idByName['Tiny Bird'], homeX: 90, homeY: 30, createdAt: Date.now() }
- ]
- };
- }
-
- function makeAsset(name, category, subtype, pixels, meta = {}, leftPixels = null) {
- const size = 16;
- const id = uid();
- const right = alignPixelsToBottom(normalizePixels(pixels, size), size);
- const asset = {
- id,
- name,
- category,
- subtype,
- size,
- pixels: encodePixels(right),
- faces: category === 'dynamic' ? { right: encodePixels(right), left: 'mirror' } : null,
- parentAssetId: null,
- originalAssetId: null,
- createdAt: Date.now(),
- updatedAt: Date.now(),
- author: 'Island Team',
- meta: buildAssetMeta(category, subtype, meta.depthPixels || [], meta.lightPixels || [], meta.lightColor || '#ffd86a', meta.door || null, size)
- };
- asset.contentHash = computeAssetContentHash(asset);
- return asset;
- }
-
-
- function alignPixelsToBottom(pixels, size) {
- const source = normalizePixels(pixels, size);
- let maxY = -1;
- for (let y = 0; y < size; y++) {
- for (let x = 0; x < size; x++) {
- if (source[y * size + x]) maxY = Math.max(maxY, y);
- }
- }
- if (maxY < 0 || maxY === size - 1) return source;
- const dy = size - 1 - maxY;
- const out = blankPixels(size);
- for (let y = 0; y < size; y++) {
- for (let x = 0; x < size; x++) {
- const value = source[y * size + x];
- if (value && y + dy < size) out[(y + dy) * size + x] = value;
- }
- }
- return out;
- }
-
- function exportData() {
- els.dataBox.value = JSON.stringify(state, null, 2);
- toast('Full JSON exported.');
- }
-
- function exportCompactData() {
- const payload = Phase2Sync?.compactState ? Phase2Sync.compactState(state) : state;
- els.dataBox.value = JSON.stringify(payload);
- toast('Compact JSON exported.');
- }
-
- function exportSnapshotData() {
- if (!Phase2Sync?.makeSnapshot) {
- exportData();
- return;
- }
- els.dataBox.value = JSON.stringify(Phase2Sync.makeSnapshot(state));
- toast('Snapshot JSON exported.');
- }
-
- function exportAssetBundleData() {
- if (!Phase2Sync?.makeAssetBundle) {
- exportData();
- return;
- }
- els.dataBox.value = JSON.stringify(Phase2Sync.makeAssetBundle(state, state.assets.map((asset) => asset.id)));
- toast('Asset bundle JSON exported.');
- }
-
- function importData() {
- try {
- const imported = JSON.parse(els.dataBox.value);
- if (imported?.format === Phase2Sync?.SNAPSHOT_FORMAT) {
- toast('Snapshot import needs matching assets; import a full/compact save or asset bundle instead.');
- return;
- }
- if (Phase2Sync?.isAssetBundle?.(imported)) {
- const importedAssets = Phase2Sync.unpackAssetBundle(imported).map(normalizeAsset);
- const byId = new Map(state.assets.map((asset) => [asset.id, asset]));
- importedAssets.forEach((asset) => byId.set(asset.id, asset));
- state.assets = [...byId.values()];
- saveState();
- spriteCache.clear();
- renderLibrary();
- updateSelectedLabel();
- toast(`${importedAssets.length} asset${importedAssets.length === 1 ? '' : 's'} imported.`);
- return;
- }
- const expanded = Phase2Sync?.isCompactState?.(imported) ? Phase2Sync.expandState(imported) : imported;
- state = normalizeState(expanded);
- rebuildWorldIndex();
- hydrateAuthorUI();
- selectedAssetId = state.assets[0]?.id ?? null;
- saveState();
- spriteCache.clear();
- hydrateRuntime();
- renderLibrary();
- updateSelectedLabel();
- toast('JSON imported.');
- } catch (error) {
- toast('Invalid JSON.');
- console.error(error);
- }
- }
-
- function resetAll() {
- if (!confirm('Reset all local assets and island objects?')) return;
- localStorage.removeItem(STORAGE_KEY);
- state = seedState();
- rebuildWorldIndex();
- hydrateAuthorUI();
- selectedAssetId = state.assets[0]?.id ?? null;
- saveState();
- spriteCache.clear();
- hydrateRuntime();
- renderLibrary();
- updateSelectedLabel();
- toast('Local world reset.');
- }
-
- function recordSyncEvent(event) {
- if (!event) return;
- state.eventLog ||= [];
- state.eventLog.push(event);
- state.eventLog = state.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300));
- state.sync ||= { lastEventId: null };
- state.sync.lastEventId = event.id;
- }
-
- function cachePhase2State() {
- if (!Phase2Sync?.cacheAssets) return;
- Phase2Sync.cacheAssets(state.assets || []).catch((error) => console.warn('Phase 2 asset cache failed.', error));
- if (Phase2Sync?.makeSnapshot && Phase2Sync?.cacheSnapshot) {
- Phase2Sync.cacheSnapshot(Phase2Sync.makeSnapshot(state)).catch((error) => console.warn('Phase 2 snapshot cache failed.', error));
- }
- }
-
- function updateSyncStats() {
- if (!els.syncStats) return;
- const report = Phase2Sync?.compactSizeReport?.(state);
- if (!report) {
- els.syncStats.textContent = 'Phase 2 codec unavailable.';
- return;
- }
- const saved = report.savedPercent > 0 ? `${report.savedPercent}% smaller` : 'no saving yet';
- const statsRect = getViewportWorldRect(64);
- const visibleChunkCount = terrainCache?.chunks?.filter((chunk) => !(chunk.x + chunk.w < statsRect.left || chunk.x > statsRect.right || chunk.y + chunk.h < statsRect.top || chunk.y > statsRect.bottom)).length || 0;
- els.syncStats.textContent = `Compact local save: ${report.compactBytes.toLocaleString()} bytes / full ${report.fullBytes.toLocaleString()} bytes (${saved}). Assets ${report.assets}, objects ${report.objects}, queued events ${state.eventLog?.length || 0}. Terrain chunks ${visibleChunkCount}/${terrainCache?.chunks?.length || 0}.`;
- }
-
- function computeAssetContentHash(asset) {
- const payload = [
- asset.category || '',
- asset.subtype || '',
- asset.size || '',
- asset.pixels || '',
- asset.faces?.right || '',
- asset.faces?.left || '',
- asset.meta?.depthPixels || '',
- JSON.stringify(asset.meta?.lightPixels || []),
- asset.meta?.door ? `${asset.meta.door.x},${asset.meta.door.y}` : ''
- ].join('|');
- return `fnv1a:${fnv1a(payload)}`;
- }
-
- function fnv1a(value) {
- let hash = 0x811c9dc5;
- for (let i = 0; i < value.length; i++) {
- hash ^= value.charCodeAt(i);
- hash = Math.imul(hash, 0x01000193) >>> 0;
- }
- return hash.toString(16).padStart(8, '0');
- }
-
- function clampInt(value, min, max, fallback = min) {
- const n = Math.round(Number(value));
- return Number.isFinite(n) ? clamp(n, min, max) : fallback;
- }
-
- function findAsset(id) {
- return state.assets.find((asset) => asset.id === id) || null;
- }
-
- function blankPixels(size) {
- return Array(size * size).fill(null);
- }
-
- function normalizePixels(pixels, size) {
- const out = blankPixels(size);
- if (typeof pixels === 'string') {
- for (let i = 0; i < Math.min(out.length, pixels.length); i++) {
- const ch = pixels[i];
- out[i] = ch === '.' ? null : (PALETTE_BY_CODE[ch] ? ch : nearestPaletteCode(ch));
- }
- return out;
- }
- if (!Array.isArray(pixels)) return out;
- for (let i = 0; i < Math.min(out.length, pixels.length); i++) {
- const value = pixels[i];
- if (!value) out[i] = null;
- else if (PALETTE_BY_CODE[value]) out[i] = value;
- else out[i] = nearestPaletteCode(value);
- }
- return out;
- }
-
- function encodePixels(pixels) {
- return normalizePixels(pixels, Math.sqrt(pixels.length) || editorSize).map((value) => value || '.').join('');
- }
-
- function colorToHex(value) {
- if (!value) return 'rgba(0,0,0,0)';
- return PALETTE_BY_CODE[value] || value;
- }
-
- function nearestPaletteCode(color) {
- if (!color || typeof color !== 'string') return null;
- if (PALETTE_BY_CODE[color]) return color;
- const rgb = parseHex(color);
- if (!rgb) return selectedColorCode;
- let best = PALETTE[0].code;
- let bestDist = Infinity;
- for (const entry of PALETTE) {
- const p = parseHex(entry.color);
- const dist = (rgb.r - p.r) ** 2 + (rgb.g - p.g) ** 2 + (rgb.b - p.b) ** 2;
- if (dist < bestDist) { bestDist = dist; best = entry.code; }
- }
- return best;
- }
-
- function readableTextColor(hex) {
- const rgb = parseHex(hex);
- if (!rgb) return '#243044';
- const yiq = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000;
- return yiq > 140 ? '#243044' : '#fffdf5';
- }
-
- function parseHex(hex) {
- if (typeof hex !== 'string' || !hex.startsWith('#')) return null;
- const clean = hex.replace('#', '');
- const full = clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean;
- const value = parseInt(full, 16);
- if (!Number.isFinite(value)) return null;
- return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 };
- }
-
-
-
- function normalizeDepthPixels(input, size) {
- const out = Array(size * size).fill(0);
- if (typeof input === 'string') {
- for (let i = 0; i < Math.min(out.length, input.length); i++) out[i] = input[i] === '1' ? 1 : 0;
- return out;
- }
- if (!Array.isArray(input)) return out;
- for (let i = 0; i < Math.min(out.length, input.length); i++) out[i] = input[i] ? 1 : 0;
- return out;
- }
-
- function encodeDepthPixels(input) {
- return Array.from(input || []).map((v) => v ? '1' : '.').join('');
- }
-
- function resizeDepthPixels(source, oldSize, newSize) {
- const normalized = normalizeDepthPixels(source, oldSize);
- const out = Array(newSize * newSize).fill(0);
- const min = Math.min(oldSize, newSize);
- const xOffset = Math.floor((newSize - min) / 2);
- const yOffset = Math.floor((newSize - min) / 2);
- const oldOffset = Math.floor((oldSize - min) / 2);
- for (let y = 0; y < min; y++) {
- for (let x = 0; x < min; x++) {
- out[(y + yOffset) * newSize + (x + xOffset)] = normalized[(y + oldOffset) * oldSize + (x + oldOffset)] || 0;
- }
- }
- return out;
- }
-
- function getAssetDepth(asset, x, y, side = 'right') {
- const depth = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size || 1);
- const sx = asset.category === 'dynamic' && side === 'left' ? asset.size - 1 - x : x;
- return depth[y * asset.size + sx] || 0;
- }
-
- function applyDepthToColor(hex, depth) {
- if (!depth) return hex;
- const rgb = parseHex(hex);
- if (!rgb) return hex;
- const amt = 34;
- const r = Math.min(255, rgb.r + amt);
- const g = Math.min(255, rgb.g + amt);
- const b = Math.min(255, rgb.b + amt);
- return `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`;
- }
-
- function resizePixels(source, oldSize, newSize) {
- const out = blankPixels(newSize);
- const min = Math.min(oldSize, newSize);
- const xOffset = Math.floor((newSize - min) / 2);
- const yOffset = Math.floor((newSize - min) / 2);
- const oldOffset = Math.floor((oldSize - min) / 2);
- for (let y = 0; y < min; y++) {
- for (let x = 0; x < min; x++) {
- out[(y + yOffset) * newSize + (x + xOffset)] = source[(y + oldOffset) * oldSize + (x + oldOffset)] || null;
- }
- }
- return out;
- }
-
- function mirrorPixels(pixels, size) {
- const out = blankPixels(size);
- for (let y = 0; y < size; y++) {
- for (let x = 0; x < size; x++) {
- out[y * size + (size - 1 - x)] = pixels[y * size + x] || null;
- }
- }
- return out;
- }
-
- function hasAnyPixel(pixels) {
- return Array.isArray(pixels) && pixels.some(Boolean);
- }
-
-
- function fade(t) {
- return t * t * t * (t * (t * 6 - 15) + 10);
- }
-
- function hash2(ix, iy) {
- let h = ix * 374761393 + iy * 668265263;
- h = (h ^ (h >> 13)) * 1274126177;
- return (h ^ (h >> 16)) >>> 0;
- }
-
- function gradDot(ix, iy, x, y) {
- const h = hash2(ix, iy) & 7;
- const gx = [1, -1, 1, -1, 1, -1, 0, 0][h];
- const gy = [1, 1, -1, -1, 0, 0, 1, -1][h];
- return gx * (x - ix) + gy * (y - iy);
- }
-
- function perlinNoise(x, y) {
- const x0 = Math.floor(x), y0 = Math.floor(y);
- const x1 = x0 + 1, y1 = y0 + 1;
- const sx = fade(x - x0), sy = fade(y - y0);
- const n00 = gradDot(x0, y0, x, y);
- const n10 = gradDot(x1, y0, x, y);
- const n01 = gradDot(x0, y1, x, y);
- const n11 = gradDot(x1, y1, x, y);
- const ix0 = lerp(n00, n10, sx);
- const ix1 = lerp(n01, n11, sx);
- return lerp(ix0, ix1, sy) * .5 + .5;
- }
-
- function fractalPerlin(x, y, octaves = 4) {
- let amp = .5;
- let freq = 1;
- let total = 0;
- let norm = 0;
- for (let i = 0; i < octaves; i++) {
- total += perlinNoise(x * freq, y * freq) * amp;
- norm += amp;
- amp *= .5;
- freq *= 2;
- }
- return total / norm;
- }
-
- function smoothNoise(x, y) {
- return Math.sin(x * .71 + y * .37) * .5 + Math.sin(x * .23 - y * .61) * .32 + Math.sin((x + y) * .17) * .18;
- }
-
- function cap(value) {
- return `${value}`.charAt(0).toUpperCase() + `${value}`.slice(1);
- }
-
-
- function buildPalette() {
- const neutrals = ['#fffdf7','#f3ead8','#dfd2bc','#c2b39d','#9b9083','#776d67','#5a5454','#403d42','#2d2d35','#14151c'];
- const hues = [0, 24, 48, 72, 96, 132, 168, 204, 228, 264, 288, 324, 348];
- const lights = [72, 58, 46, 34];
- const colors = [...neutrals];
- for (const light of lights) {
- for (const hue of hues) colors.push(hslToHex(hue, 72, light));
- }
- return COLOR_CODES.split('').map((code, index) => ({ code, color: colors[index] }));
- }
-
- function pseudoNoise(value) {
- const x = Math.sin(value * 12.9898 + 78.233) * 43758.5453;
- return x - Math.floor(x);
- }
-
- function hslToHex(h, s, l) {
- s /= 100; l /= 100;
- const k = (n) => (n + h / 30) % 12;
- const a = s * Math.min(l, 1 - l);
- const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
- const toHex = (value) => Math.round(255 * value).toString(16).padStart(2, '0');
- return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`;
- }
-
- function hexToRgba(hex, alpha) {
- const clean = hex.replace('#', '');
- const bigint = parseInt(clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean, 16);
- const r = (bigint >> 16) & 255;
- const g = (bigint >> 8) & 255;
- const b = bigint & 255;
- return `rgba(${r}, ${g}, ${b}, ${alpha})`;
- }
-
- function toast(message) {
- clearTimeout(toastTimer);
- els.toast.textContent = message;
- els.toast.hidden = false;
- toastTimer = setTimeout(() => { els.toast.hidden = true; }, 1900);
- }
-
- function px(pixels, size, x, y, color) {
- if (x >= 0 && x < size && y >= 0 && y < size) pixels[y * size + x] = color;
- }
- function rect(pixels, size, x, y, w, h, color) {
- for (let yy = y; yy < y + h; yy++) for (let xx = x; xx < x + w; xx++) px(pixels, size, xx, yy, color);
- }
- function circle(pixels, size, cx, cy, r, color) {
- for (let y = Math.floor(cy - r); y <= Math.ceil(cy + r); y++) {
- for (let x = Math.floor(cx - r); x <= Math.ceil(cx + r); x++) {
- if ((x - cx) ** 2 + (y - cy) ** 2 <= r ** 2) px(pixels, size, x, y, color);
- }
- }
- }
-
- function drawHouse() {
- const s = 16, p = blankPixels(s);
- rect(p, s, 4, 8, 8, 6, '#c57955');
- rect(p, s, 6, 10, 3, 4, '#423044');
- rect(p, s, 10, 9, 2, 2, '#ffdd7d');
- rect(p, s, 4, 7, 8, 1, '#9b5163');
- for (let y = 3; y <= 7; y++) {
- for (let x = 3 + Math.abs(6 - y); x <= 12 - Math.abs(6 - y); x++) px(p, s, x, y, '#81495c');
- }
- rect(p, s, 2, 8, 12, 1, '#654154');
- return p;
- }
-
- function drawTree() {
- const s = 16, p = blankPixels(s);
- rect(p, s, 7, 9, 2, 5, '#805032');
- circle(p, s, 8, 5, 4, '#58b869');
- circle(p, s, 5, 7, 3, '#4aa95d');
- circle(p, s, 11, 7, 3, '#4aa95d');
- circle(p, s, 8, 8, 4, '#63ca75');
- px(p, s, 6, 5, '#8bef8f');
- px(p, s, 10, 4, '#8bef8f');
- return p;
- }
-
- function drawLantern() {
- const s = 16, p = blankPixels(s);
- rect(p, s, 7, 5, 2, 8, '#514b60');
- rect(p, s, 5, 4, 6, 1, '#514b60');
- rect(p, s, 6, 5, 4, 4, '#b8e8ff');
- rect(p, s, 3, 13, 10, 1, '#86623f');
- px(p, s, 5, 9, '#514b60'); px(p, s, 10, 9, '#514b60');
- return p;
- }
-
- function drawHumanRight() {
- const s = 16, p = blankPixels(s);
- rect(p, s, 7, 3, 3, 3, '#e6b887');
- rect(p, s, 6, 6, 5, 5, '#5d8bea');
- px(p, s, 10, 4, '#273046');
- rect(p, s, 5, 7, 1, 3, '#e6b887'); rect(p, s, 11, 7, 1, 3, '#e6b887');
- rect(p, s, 6, 11, 2, 3, '#31354f'); rect(p, s, 9, 11, 2, 3, '#31354f');
- rect(p, s, 6, 2, 5, 1, '#3a2b35');
- return p;
- }
- function drawHumanLeft() { return mirrorPixels(drawHumanRight(), 16); }
-
- function drawDogRight() {
- const s = 16, p = blankPixels(s);
- rect(p, s, 4, 8, 7, 4, '#b77a46');
- rect(p, s, 10, 7, 3, 3, '#c98952');
- px(p, s, 12, 8, '#2f1e17');
- rect(p, s, 5, 12, 1, 2, '#754b31'); rect(p, s, 9, 12, 1, 2, '#754b31');
- px(p, s, 3, 8, '#b77a46'); px(p, s, 2, 7, '#b77a46');
- return p;
- }
- function drawDogLeft() { return mirrorPixels(drawDogRight(), 16); }
-
- function drawFishRight() {
- const s = 16, p = blankPixels(s);
- rect(p, s, 5, 7, 6, 3, '#4bd7ff');
- px(p, s, 11, 8, '#e7fbff'); px(p, s, 4, 7, '#258ac0'); px(p, s, 3, 6, '#258ac0'); px(p, s, 3, 10, '#258ac0');
- px(p, s, 6, 6, '#90edff'); px(p, s, 8, 10, '#90edff');
- return p;
- }
- function drawFishLeft() { return mirrorPixels(drawFishRight(), 16); }
-
- function drawBirdRight() {
- const s = 16, p = blankPixels(s);
- rect(p, s, 7, 6, 3, 3, '#f4d45f');
- px(p, s, 10, 7, '#f09062');
- rect(p, s, 4, 6, 3, 1, '#6da9ef');
- rect(p, s, 10, 5, 3, 1, '#6da9ef');
- px(p, s, 8, 5, '#242434');
- return p;
- }
- function drawBirdLeft() { return mirrorPixels(drawBirdRight(), 16); }
-
- bootstrap();
-})();
diff --git a/index.html b/index.html
index eefd087..ba16f62 100644
--- a/index.html
+++ b/index.html
@@ -60,6 +60,7 @@
@@ -67,7 +68,7 @@
-
-
Name
+ Name
@@ -179,6 +180,22 @@
+
+
Local save
@@ -198,21 +215,6 @@
-
-
-
Phase 5 guardrails
Local pre-server checks for object volume, orphaned placements, terrain compatibility, hidden objects, and report logs.
@@ -270,7 +272,11 @@
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
Pixel Studio
-
Make static scenery or moving island life.
-
-
-
-
-
-
-
-
-
-
- Canvas size
-
-
- Role
-
-
-
-
-
Name
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Light/Depth tools are here. Depth paints visible blue height marks; Shift/right-click erases.
-
-
-
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.
-
-
-
-
Finish
-
-
-
-
-
-
-
-
-
-
-
-
-
Click an asset to select it and jump to it on the map. Copy Edit creates a new derivative.
-
-
-
-
-
-
-
-
Local save
-
Saved to this browser only. Export JSON if you want to move or share a local world.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Name
-
by Author
-
-
Remixed: 0
-
-
- 0
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/index.html.bak b/index.html.bak
deleted file mode 100644
index 7a6c57e..0000000
--- a/index.html.bak
+++ /dev/null
@@ -1,203 +0,0 @@
-
-
-