pixel/app.js.bak
2026-06-01 22:48:15 +09:00

3774 lines
145 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
'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 = `<strong></strong><span>${displayCategory(asset)} / ${cap(asset.subtype)} / ${asset.size}×${asset.size}${lineage}</span><span></span>`;
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 = `<strong></strong><span>Author: ${asset.author || 'Local Artist'}</span>`;
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();
})();