2947 lines
111 KiB
JavaScript
2947 lines
111 KiB
JavaScript
(() => {
|
|
'use strict';
|
|
|
|
console.info('Pixel Island Summoner loaded');
|
|
|
|
const STORAGE_KEY = 'pixel-island-summoner';
|
|
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 COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
const PALETTE = buildPalette();
|
|
const PALETTE_BY_CODE = Object.fromEntries(PALETTE.map((p) => [p.code, p.color]));
|
|
const Modules = window.PixelIslandModules || {};
|
|
const Lighting = Modules.Lighting;
|
|
const RenderPipeline = Modules.RenderPipeline;
|
|
const EditorActions = Modules.EditorActions;
|
|
|
|
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'),
|
|
settingsToggle: $('settingsToggle'), settingsPanel: $('settingsPanel'), lightingToggle: $('lightingToggle'),
|
|
|
|
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'), toolLight: $('toolLight'), toolDoor: $('toolDoor'), clearPaint: $('clearPaint'),
|
|
undoPaint: $('undoPaint'), redoPaint: $('redoPaint'), mirrorLeft: $('mirrorLeft'),
|
|
paintCanvas: $('paintCanvas'), editHint: $('editHint'), paletteGrid: $('paletteGrid'),
|
|
setOutline: $('setOutline'), clearOutline: $('clearOutline'), outlineStatus: $('outlineStatus'),
|
|
lightColor: $('lightColor'), staticSettingsPanel: $('staticSettingsPanel'), dynamicSettingsPanel: $('dynamicSettingsPanel'), doorMarkerHint: $('doorMarkerHint'),
|
|
settingsSummary: $('settingsSummary'), saveAsset: $('saveAsset'), saveAndPlace: $('saveAndPlace'), newAsset: $('newAsset'),
|
|
lineageNote: $('lineageNote'), assetList: $('assetList'),
|
|
exportData: $('exportData'), importData: $('importData'), resetAll: $('resetAll'), dataBox: $('dataBox')
|
|
};
|
|
|
|
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();
|
|
const appState = {
|
|
get world() { return world; },
|
|
get view() { return view; },
|
|
get settings() {
|
|
state.settings ||= {};
|
|
return state.settings;
|
|
}
|
|
};
|
|
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 saveTimer = null;
|
|
|
|
let editorSize = 8;
|
|
let editorPixels = blankPixels(8);
|
|
let editorLeftPixels = blankPixels(8);
|
|
let editingSide = 'right';
|
|
let paintTool = 'brush';
|
|
let selectedColorCode = 'a';
|
|
let outlineColorCode = null;
|
|
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 spriteShadeCache = new WeakMap();
|
|
let selectedObject = null;
|
|
let renderPhase = null;
|
|
let editorView = { zoom: 1, x: 0, y: 0 };
|
|
let editorPointer = { panning: false, pointerId: null, lastX: 0, lastY: 0 };
|
|
let editorUndoStack = [];
|
|
let editorRedoStack = [];
|
|
let editorStrokeSnapshot = null;
|
|
let lastPointerPaintTime = 0;
|
|
|
|
function bootstrap() {
|
|
resizeCanvas();
|
|
resetView(false);
|
|
hydrateRuntime();
|
|
wireUI();
|
|
renderPalette();
|
|
updateOutlineStatus();
|
|
hydrateAuthorUI();
|
|
refreshCategoryUI();
|
|
setupEditor(8, blankPixels(8), blankPixels(8));
|
|
renderLibrary();
|
|
updateSelectedLabel();
|
|
requestAnimationFrame(tick);
|
|
}
|
|
|
|
function wireUI() {
|
|
window.addEventListener('resize', () => {
|
|
resizeCanvas();
|
|
render();
|
|
});
|
|
|
|
els.openEditor.addEventListener('click', () => setDrawerOpen(true));
|
|
els.closeEditor.addEventListener('click', () => setDrawerOpen(false));
|
|
els.settingsToggle?.addEventListener('click', () => {
|
|
if (els.settingsPanel) els.settingsPanel.hidden = !els.settingsPanel.hidden;
|
|
});
|
|
els.lightingToggle?.addEventListener('change', () => {
|
|
appState.settings.lightingEnabled = els.lightingToggle.checked;
|
|
saveState();
|
|
});
|
|
|
|
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';
|
|
scheduleSaveState();
|
|
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);
|
|
|
|
els.assetSize.addEventListener('change', () => {
|
|
const nextSize = Number(els.assetSize.value);
|
|
setupEditor(nextSize, resizePixels(getActiveEditorPixels(), editorSize, nextSize), resizePixels(editorLeftPixels, editorSize, nextSize));
|
|
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();
|
|
drawEditor();
|
|
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.toolLight.addEventListener('click', () => setPaintTool('light'));
|
|
els.toolDoor.addEventListener('click', () => setPaintTool('door'));
|
|
els.clearPaint.addEventListener('click', () => {
|
|
applyEditorMutation(() => {
|
|
if (editingSide === 'left') editorLeftPixels = blankPixels(editorSize);
|
|
else editorPixels = blankPixels(editorSize);
|
|
clearLightsForActiveSide();
|
|
drawEditor();
|
|
});
|
|
});
|
|
els.setOutline?.addEventListener('click', () => {
|
|
applyEditorMutation(() => {
|
|
outlineColorCode = selectedColorCode;
|
|
updateOutlineStatus();
|
|
clearSpriteCaches();
|
|
drawEditor();
|
|
});
|
|
});
|
|
els.clearOutline?.addEventListener('click', () => {
|
|
applyEditorMutation(() => {
|
|
outlineColorCode = null;
|
|
updateOutlineStatus();
|
|
clearSpriteCaches();
|
|
drawEditor();
|
|
});
|
|
});
|
|
els.undoPaint?.addEventListener('click', undoEditor);
|
|
els.redoPaint?.addEventListener('click', redoEditor);
|
|
els.mirrorLeft?.addEventListener('click', mirrorLeftFromRight);
|
|
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; commitEditorStroke(); lastPaintedKey = ''; editorPointer.panning = false; });
|
|
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.resetAll.addEventListener('click', resetAll);
|
|
}
|
|
|
|
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 onEditorKeyDown(event) {
|
|
if (!els.drawer?.classList.contains('open')) return;
|
|
const target = event.target;
|
|
if (target && ['INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName)) return;
|
|
const key = event.key.toLowerCase();
|
|
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 (key === 'b') setPaintTool('brush');
|
|
else if (key === 'e') setPaintTool('erase');
|
|
else if (key === 'f') setPaintTool('fill');
|
|
else if (key === 'i') setPaintTool('pick');
|
|
}
|
|
|
|
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, false);
|
|
toggleHidden(els.toolDoor, role !== 'building');
|
|
toggleHidden(els.mirrorLeft, isStatic);
|
|
toggleHidden(els.doorMarkerHint, role !== 'building');
|
|
|
|
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 cleanTextMap = {
|
|
human: 'Humans use Right and Left sprites. They hop often and visit buildings.',
|
|
animal: 'Animals use Right and Left sprites. They hop often and prefer nature.',
|
|
nature: 'Nature attracts animals and birds. Static sprites are drawn at 2x scale.',
|
|
building: 'Buildings attract humans. Use Door to mark the entrance.',
|
|
other: 'Other objects are neutral scenery and render at 2x scale.'
|
|
};
|
|
els.roleHint.textContent = cleanTextMap[role] || cleanTextMap.other;
|
|
}
|
|
|
|
function updateSettingsSummary() {
|
|
if (!els.settingsSummary) return;
|
|
const role = currentRole();
|
|
const isStaticRole = roleToCategory(role) === 'static';
|
|
if (isStaticRole) {
|
|
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)} / 2x static pixels / ${lightText}${doorText}.`;
|
|
} else {
|
|
const hasLeft = hasAnyPixel(editorLeftPixels);
|
|
els.settingsSummary.textContent = `${cap(role)} / Right first / Left ${hasLeft ? 'ready' : 'auto-mirror suggested'}.`;
|
|
}
|
|
}
|
|
|
|
function setPaintTool(tool) {
|
|
paintTool = tool;
|
|
[els.toolBrush, els.toolErase, els.toolFill, els.toolPick, els.toolLight, els.toolDoor].filter(Boolean).forEach((button) => button.classList.remove('active'));
|
|
({ brush: els.toolBrush, erase: els.toolErase, fill: els.toolFill, pick: els.toolPick, light: els.toolLight, door: els.toolDoor }[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 connected pixels with the selected palette color.',
|
|
pick: 'Pick a pixel color from the canvas.',
|
|
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.'
|
|
};
|
|
els.editHint.textContent = hints[tool];
|
|
}
|
|
|
|
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 getDrawableItems(time = performance.now()) {
|
|
const items = [];
|
|
for (const placed of state.placed) {
|
|
const asset = findAsset(placed.assetId);
|
|
if (!asset || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[placed.id]) continue;
|
|
items.push({ kind: 'static', asset, x: placed.x + .5, y: placed.y + .5, 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;
|
|
items.push({ kind: 'dynamic', asset, x: runtime.x, y: runtime.y, source: runtime });
|
|
}
|
|
items.sort((a, b) => (a.x + a.y) - (b.x + b.y) || a.y - b.y);
|
|
return items;
|
|
}
|
|
|
|
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);
|
|
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 = state.placed.filter((p) => p.x === hoverTile.x && p.y === hoverTile.y).length;
|
|
const dynamicCount = state.dynamicSummons.filter((p) => Math.round(p.homeX) === hoverTile.x && Math.round(p.homeY) === 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 = state.placed.filter((p) => p.x === x && p.y === y);
|
|
const dynamicObjects = state.dynamicSummons.filter((p) => Math.round(p.homeX) === x && Math.round(p.homeY) === 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();
|
|
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() };
|
|
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();
|
|
toast(`${asset.name} moved.`);
|
|
} else {
|
|
const summon = { id: uid(), assetId: asset.id, homeX: x, homeY: y, createdAt: Date.now() };
|
|
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());
|
|
}
|
|
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) {
|
|
for (let i = state.placed.length - 1; i >= 0; i--) {
|
|
const item = state.placed[i];
|
|
if (item.x === x && item.y === y) {
|
|
const asset = findAsset(item.assetId);
|
|
state.placed.splice(i, 1);
|
|
if (selectedObject?.id === item.id) selectedObject = null;
|
|
saveState();
|
|
toast(`${asset?.name ?? 'Object'} removed.`);
|
|
return;
|
|
}
|
|
}
|
|
for (let i = state.dynamicSummons.length - 1; i >= 0; i--) {
|
|
const item = state.dynamicSummons[i];
|
|
if (Math.round(item.homeX) === x && Math.round(item.homeY) === y) {
|
|
const asset = findAsset(item.assetId);
|
|
state.dynamicSummons.splice(i, 1);
|
|
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);
|
|
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);
|
|
if (selectedObject?.id === objectId) selectedObject = null;
|
|
hydrateRuntime();
|
|
saveState();
|
|
updateSelectionBubble(performance.now());
|
|
toast(`${asset?.name ?? 'Dynamic object'} removed.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function onPaintPointerDown(event) {
|
|
event.preventDefault();
|
|
lastPointerPaintTime = performance.now();
|
|
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;
|
|
editorStrokeSnapshot = isPainting ? getEditorSnapshot() : null;
|
|
mousePaint.active = false;
|
|
mousePaint.panning = false;
|
|
if (isPainting) paintAtClient(event.clientX, event.clientY, event.button || 0, event.shiftKey);
|
|
if (paintTool === 'fill' || paintTool === 'pick') isPainting = false;
|
|
}
|
|
|
|
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 (!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;
|
|
isPainting = false;
|
|
commitEditorStroke();
|
|
lastPaintedKey = '';
|
|
}
|
|
|
|
function onPaintClick(event) {
|
|
if (event.button !== 0) return;
|
|
event.preventDefault();
|
|
lastPaintedKey = '';
|
|
if (!editorStrokeSnapshot) editorStrokeSnapshot = getEditorSnapshot();
|
|
paintAtClient(event.clientX, event.clientY, 0, event.shiftKey);
|
|
commitEditorStroke();
|
|
}
|
|
|
|
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 (performance.now() - lastPointerPaintTime < 500) return;
|
|
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 };
|
|
editorStrokeSnapshot = mousePaint.active ? getEditorSnapshot() : null;
|
|
if (mousePaint.active) paintAtClient(event.clientX, event.clientY, event.button, event.shiftKey);
|
|
if (paintTool === 'fill' || paintTool === 'pick') mousePaint.active = false;
|
|
}
|
|
|
|
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;
|
|
commitEditorStroke();
|
|
lastPaintedKey = '';
|
|
}
|
|
|
|
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},${shiftKey}`;
|
|
if (key === lastPaintedKey) return;
|
|
lastPaintedKey = key;
|
|
|
|
const pixels = getActiveEditorPixels();
|
|
const index = y * editorSize + x;
|
|
let changed = false;
|
|
if (paintTool === 'brush') {
|
|
changed = pixels[index] !== selectedColorCode;
|
|
pixels[index] = selectedColorCode;
|
|
} else if (paintTool === 'erase') {
|
|
changed = pixels[index] !== null || hasLightPixel(x, y);
|
|
pixels[index] = null;
|
|
removeLightPixel(x, y);
|
|
} else if (paintTool === 'fill') {
|
|
changed = fillPixels(x, y, selectedColorCode);
|
|
} else if (paintTool === 'pick') {
|
|
if (pixels[index]) selectPaletteColor(pixels[index]);
|
|
return;
|
|
} else if (paintTool === 'light') {
|
|
changed = true;
|
|
if (shiftKey || button === 2) removeLightPixel(x, y);
|
|
else addLightPixel(x, y, selectedColorCode);
|
|
updateSettingsSummary();
|
|
} else if (paintTool === 'door') {
|
|
if (roleToCategory(currentRole()) === 'static' && currentRole() === 'building') {
|
|
changed = doorPixel.x !== x || doorPixel.y !== y;
|
|
doorPixel = { x, y };
|
|
updateSettingsSummary();
|
|
}
|
|
}
|
|
if (changed) {
|
|
drawEditor();
|
|
}
|
|
}
|
|
|
|
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 getActiveEditorPixels() {
|
|
return editingSide === 'left' ? editorLeftPixels : editorPixels;
|
|
}
|
|
|
|
function setupEditor(size, rightPixels, leftPixels) {
|
|
editorSize = size;
|
|
editorPixels = normalizePixels(rightPixels, size);
|
|
editorLeftPixels = normalizePixels(leftPixels, 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();
|
|
resetEditorHistory();
|
|
drawEditor();
|
|
}
|
|
|
|
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 = getActiveEditorPixels();
|
|
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 (outlineColorCode) {
|
|
const pixelsForOutline = getActiveEditorPixels();
|
|
pctx.fillStyle = colorToHex(outlineColorCode);
|
|
for (let y = 0; y < editorSize; y++) {
|
|
for (let x = 0; x < editorSize; x++) {
|
|
if (!pixelsForOutline[y * editorSize + x]) continue;
|
|
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
|
|
const nx = x + dx, ny = y + dy;
|
|
if (nx < 0 || ny < 0 || nx >= editorSize || ny >= editorSize || !pixelsForOutline[ny * editorSize + nx]) {
|
|
pctx.fillRect((x + dx * 0.18) * cell, (y + dy * 0.18) * cell, Math.ceil(cell), Math.ceil(cell));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Redraw pixels over the outline preview.
|
|
for (let y = 0; y < editorSize; y++) {
|
|
for (let x = 0; x < editorSize; x++) {
|
|
const color = pixelsForOutline[y * editorSize + x];
|
|
if (!color) continue;
|
|
pctx.fillStyle = colorToHex(color);
|
|
pctx.fillRect(x * cell, y * cell, Math.ceil(cell), Math.ceil(cell));
|
|
}
|
|
}
|
|
}
|
|
|
|
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 lx = (light.x + .5) * cell;
|
|
const ly = (light.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);
|
|
}
|
|
}
|
|
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 hasLightPixel(x, y) {
|
|
return lightPixels.some((p) => p.x === x && p.y === y);
|
|
}
|
|
|
|
function clearLightsForActiveSide() {
|
|
if (editingSide === 'right') lightPixels = [];
|
|
}
|
|
|
|
function fillPixels(startX, startY, colorCode) {
|
|
const pixels = getActiveEditorPixels();
|
|
const target = pixels[startY * editorSize + startX] || null;
|
|
if (target === colorCode) return false;
|
|
const queue = [[startX, startY]];
|
|
let changed = false;
|
|
while (queue.length) {
|
|
const [x, y] = queue.pop();
|
|
if (x < 0 || y < 0 || x >= editorSize || y >= editorSize) continue;
|
|
const index = y * editorSize + x;
|
|
if ((pixels[index] || null) !== target) continue;
|
|
pixels[index] = colorCode;
|
|
changed = true;
|
|
queue.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function getEditorSnapshot() {
|
|
return {
|
|
right: editorPixels.slice(),
|
|
left: editorLeftPixels.slice(),
|
|
lights: lightPixels.map((p) => ({ ...p })),
|
|
outline: outlineColorCode,
|
|
door: { ...doorPixel },
|
|
side: editingSide
|
|
};
|
|
}
|
|
|
|
function restoreEditorSnapshot(snapshot) {
|
|
editorPixels = snapshot.right.slice();
|
|
editorLeftPixels = snapshot.left.slice();
|
|
lightPixels = snapshot.lights.map((p) => ({ ...p }));
|
|
outlineColorCode = snapshot.outline;
|
|
doorPixel = { ...snapshot.door };
|
|
editingSide = snapshot.side;
|
|
updateOutlineStatus();
|
|
updateSideButtons();
|
|
updateSettingsSummary();
|
|
clearSpriteCaches();
|
|
drawEditor();
|
|
}
|
|
|
|
function snapshotsEqual(a, b) {
|
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
}
|
|
|
|
function pushEditorHistory(snapshot = getEditorSnapshot()) {
|
|
editorUndoStack.push(snapshot);
|
|
if (editorUndoStack.length > 80) editorUndoStack.shift();
|
|
editorRedoStack = [];
|
|
updateHistoryButtons();
|
|
}
|
|
|
|
function applyEditorMutation(mutate) {
|
|
if (EditorActions?.apply) EditorActions.apply(getEditorSnapshot, pushEditorHistory, mutate);
|
|
else {
|
|
pushEditorHistory();
|
|
mutate();
|
|
}
|
|
}
|
|
|
|
function commitEditorStroke() {
|
|
if (!editorStrokeSnapshot) return;
|
|
const before = editorStrokeSnapshot;
|
|
editorStrokeSnapshot = null;
|
|
if (!snapshotsEqual(before, getEditorSnapshot())) pushEditorHistory(before);
|
|
}
|
|
|
|
function undoEditor() {
|
|
if (!editorUndoStack.length) return;
|
|
const current = getEditorSnapshot();
|
|
const previous = editorUndoStack.pop();
|
|
editorRedoStack.push(current);
|
|
restoreEditorSnapshot(previous);
|
|
updateHistoryButtons();
|
|
}
|
|
|
|
function redoEditor() {
|
|
if (!editorRedoStack.length) return;
|
|
const current = getEditorSnapshot();
|
|
const next = editorRedoStack.pop();
|
|
editorUndoStack.push(current);
|
|
restoreEditorSnapshot(next);
|
|
updateHistoryButtons();
|
|
}
|
|
|
|
function updateHistoryButtons() {
|
|
if (els.undoPaint) els.undoPaint.disabled = editorUndoStack.length === 0;
|
|
if (els.redoPaint) els.redoPaint.disabled = editorRedoStack.length === 0;
|
|
}
|
|
|
|
function resetEditorHistory() {
|
|
editorUndoStack = [];
|
|
editorRedoStack = [];
|
|
editorStrokeSnapshot = null;
|
|
updateHistoryButtons();
|
|
}
|
|
|
|
function mirrorLeftFromRight() {
|
|
if (roleToCategory(currentRole()) !== 'dynamic') return;
|
|
applyEditorMutation(() => {
|
|
editorLeftPixels = mirrorPixels(editorPixels, editorSize);
|
|
editingSide = 'left';
|
|
updateSideButtons();
|
|
drawEditor();
|
|
});
|
|
toast('Left sprite mirrored from Right.');
|
|
}
|
|
|
|
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 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(editorPixels),
|
|
faces: category === 'dynamic' ? {
|
|
right: encodePixels(editorPixels),
|
|
left: encodePixels(hasAnyPixel(editorLeftPixels) ? editorLeftPixels : mirrorPixels(editorPixels, size))
|
|
} : null,
|
|
meta: {
|
|
hasLight: lightPixels.length > 0,
|
|
lightPixels: lightPixels.map((p) => ({ x: Math.floor(p.x), y: Math.floor(p.y), c: p.c || selectedColorCode })),
|
|
lightColor: selectedColorCode,
|
|
outlineColor: outlineColorCode,
|
|
door: category === 'static' && subtype === 'building' ? { ...doorPixel } : null
|
|
}
|
|
};
|
|
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;
|
|
editParentId = null;
|
|
editOriginalId = null;
|
|
editingAssetId = null;
|
|
editingAssetId = asset.id;
|
|
saveState();
|
|
clearSpriteCaches();
|
|
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 = [];
|
|
outlineColorCode = null;
|
|
updateOutlineStatus();
|
|
doorPixel = { x: Math.floor(editorSize / 2), y: editorSize - 1 };
|
|
setupEditor(8, blankPixels(8), blankPixels(8));
|
|
refreshCategoryUI();
|
|
els.lineageNote.textContent = '';
|
|
}
|
|
|
|
|
|
function hydrateAuthorUI() {
|
|
state.authorName = state.authorName || 'Local Artist';
|
|
if (els.authorName) els.authorName.value = state.authorName;
|
|
renderSettingsUI();
|
|
}
|
|
|
|
function renderSettingsUI() {
|
|
if (els.lightingToggle) els.lightingToggle.checked = appState.settings.lightingEnabled !== false;
|
|
}
|
|
|
|
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 = '';
|
|
if (!state.assets.length) {
|
|
els.assetList.textContent = 'No assets yet.';
|
|
return;
|
|
}
|
|
for (const asset of state.assets) {
|
|
if (state.hiddenAssets?.[asset.id]) continue;
|
|
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)} / ${asset.size}x${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(`Up ${assetVotes.up}`, (event) => { event.stopPropagation(); voteAsset(asset.id, 1); });
|
|
const down = makeButton(`Down ${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);
|
|
els.assetList.append(card);
|
|
}
|
|
}
|
|
|
|
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', () => {
|
|
selectPaletteColor(entry.code);
|
|
});
|
|
els.paletteGrid.append(swatch);
|
|
}
|
|
els.paintColor.value = PALETTE_BY_CODE[selectedColorCode] || '#6bd06b';
|
|
}
|
|
|
|
function selectPaletteColor(code) {
|
|
if (!PALETTE_BY_CODE[code]) return;
|
|
selectedColorCode = code;
|
|
els.paintColor.value = PALETTE_BY_CODE[code];
|
|
renderPalette();
|
|
drawEditor();
|
|
}
|
|
|
|
function updateOutlineStatus() {
|
|
if (!els.outlineStatus) return;
|
|
els.outlineStatus.textContent = outlineColorCode ? `Outline: ${outlineColorCode}` : 'Outline: none';
|
|
els.outlineStatus.style.setProperty('--outline-swatch', outlineColorCode ? colorToHex(outlineColorCode) : 'transparent');
|
|
}
|
|
|
|
function displayCategory(asset) {
|
|
return roleLabelForAsset(asset);
|
|
}
|
|
|
|
function roleLabelForAsset(asset) {
|
|
const subtype = asset?.subtype || 'other';
|
|
return {
|
|
human: 'Human',
|
|
animal: 'Animal',
|
|
fish: 'Fish',
|
|
bird: 'Bird',
|
|
nature: 'Nature',
|
|
building: 'Building',
|
|
water: 'Water',
|
|
other: 'Other'
|
|
}[subtype] || cap(subtype);
|
|
}
|
|
|
|
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 = asset.faces?.right || asset.pixels || blankPixels(asset.size);
|
|
const left = asset.faces?.left || mirrorPixels(right, asset.size);
|
|
lightPixels = (asset.meta?.lightPixels || []).map((p) => ({ x: p.x, y: p.y, c: p.c || asset.meta?.lightColor || selectedColorCode }));
|
|
outlineColorCode = asset.meta?.outlineColor || null;
|
|
updateOutlineStatus();
|
|
doorPixel = asset.meta?.door || { x: Math.floor(asset.size / 2), y: asset.size - 1 };
|
|
setupEditor(asset.size, right, left);
|
|
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();
|
|
clearSpriteCaches();
|
|
hydrateRuntime();
|
|
renderLibrary();
|
|
updateSelectedLabel();
|
|
}
|
|
|
|
function updateSelectedLabel() {
|
|
if (!els.selectedAssetName) return;
|
|
const asset = findAsset(selectedAssetId);
|
|
els.selectedAssetName.textContent = asset ? `Selected: ${asset.name} (${displayCategory(asset)})` : '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 delta = { x: item.targetX - item.x, y: item.targetY - item.y };
|
|
const distance = Modules.Vec2?.length ? Modules.Vec2.length(delta) : Math.hypot(delta.x, delta.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 = delta.x;
|
|
const dy = delta.y;
|
|
const len = distance || 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;
|
|
for (const placed of state.placed) {
|
|
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 tick(time) {
|
|
const dt = Math.min(.05, (time - lastRuntimeUpdate) / 1000);
|
|
lastRuntimeUpdate = time;
|
|
updateDynamicRuntime(dt, time);
|
|
updateClock();
|
|
render(time);
|
|
requestAnimationFrame(tick);
|
|
}
|
|
|
|
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) {
|
|
if (Lighting?.getShadowForMinute) return Lighting.getShadowForMinute(minute);
|
|
const light = getCelestialLightForMinute(minute);
|
|
const elevation = light.elevation;
|
|
const length = lerp(light.isNight ? 1.35 : 1.9, light.isNight ? .85 : .52, elevation);
|
|
return {
|
|
x: -light.x * 6.5 * length,
|
|
y: Math.min(-1.8, light.y * 4.2 * length),
|
|
length,
|
|
alpha: light.isNight ? .085 : lerp(.27, .12, elevation)
|
|
};
|
|
}
|
|
|
|
function getCelestialLightForMinute(minute) {
|
|
if (Lighting?.getCelestialLightForMinute) return Lighting.getCelestialLightForMinute(minute);
|
|
const isNight = minute >= 6;
|
|
const local = isNight ? (minute - 6) / 4 : minute / 6;
|
|
const t = clamp(local, 0, 1);
|
|
const eased = t * t * (3 - 2 * t);
|
|
const elevation = Math.max(.08, Math.sin(t * Math.PI));
|
|
const sourceX = isNight ? lerp(-1.05, 1.05, eased) : lerp(1.18, -1.18, eased);
|
|
const sourceY = isNight ? -.46 - elevation * .24 : -.58 - elevation * .34;
|
|
return {
|
|
x: sourceX,
|
|
y: sourceY,
|
|
elevation,
|
|
isNight,
|
|
shadeAlpha: isNight ? .18 : lerp(.20, .08, elevation)
|
|
};
|
|
}
|
|
|
|
function getPhase() {
|
|
if (Lighting?.getPhase) return Lighting.getPhase(DAY_MS);
|
|
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)})`,
|
|
light: getCelestialLightForMinute(minute),
|
|
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();
|
|
const lightingEnabled = appState.settings.lightingEnabled !== false;
|
|
renderPhase = phase;
|
|
|
|
ctx.save();
|
|
ctx.translate(view.x, view.y);
|
|
ctx.scale(view.zoom, view.zoom);
|
|
ctx.imageSmoothingEnabled = false;
|
|
const lightSources = [];
|
|
const renderContext = { time, phase, lightingEnabled, lightSources };
|
|
const stages = [
|
|
drawTerrainStage,
|
|
drawTerrainShadeStage,
|
|
drawHoverStage,
|
|
drawObjectsStage,
|
|
drawEffectsStage
|
|
];
|
|
if (RenderPipeline?.run) RenderPipeline.run(stages, renderContext);
|
|
else stages.forEach((stage) => stage(renderContext));
|
|
ctx.restore();
|
|
|
|
updateSelectionBubble(time);
|
|
if (lightingEnabled && phase.tint !== 'rgba(255, 255, 255, 0)') {
|
|
ctx.fillStyle = phase.tint;
|
|
ctx.fillRect(0, 0, cw, ch);
|
|
}
|
|
if (lightingEnabled && phase.darkness > 0) {
|
|
ctx.fillStyle = `rgba(12, 19, 45, ${phase.darkness})`;
|
|
ctx.fillRect(0, 0, cw, ch);
|
|
drawLightSources(lightSources, phase.darkness);
|
|
}
|
|
}
|
|
|
|
function drawTerrainStage() {
|
|
ctx.drawImage(terrainCache.canvas, 0, 0);
|
|
}
|
|
|
|
function drawTerrainShadeStage({ phase, lightingEnabled }) {
|
|
if (lightingEnabled && view.zoom >= 0.78) drawTerrainShade(phase);
|
|
}
|
|
|
|
function drawHoverStage() {
|
|
drawHoverTile();
|
|
}
|
|
|
|
function drawObjectsStage({ time, phase, lightingEnabled, lightSources }) {
|
|
drawObjects(time, lightSources, lightingEnabled ? phase : null);
|
|
}
|
|
|
|
function drawEffectsStage({ time }) {
|
|
drawBubbleParticles(time);
|
|
if (view.zoom >= 0.7) {
|
|
drawSpawnEffects(time);
|
|
drawConfettiParticles(time);
|
|
}
|
|
}
|
|
|
|
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 drawTerrainShade(phase) {
|
|
const light = phase?.light;
|
|
if (!light) return;
|
|
const alphaBase = light.shadeAlpha || .12;
|
|
const left = -view.x / view.zoom - TILE_W;
|
|
const top = -view.y / view.zoom - TILE_H * 3;
|
|
const right = left + cw / view.zoom + TILE_W * 2;
|
|
const bottom = top + ch / view.zoom + TILE_H * 5;
|
|
ctx.save();
|
|
for (const tile of world.tiles) {
|
|
if (tile.type === 'water') continue;
|
|
const pos = tile.worldPos || tileToWorld(tile.x, tile.y);
|
|
if (pos.x < left || pos.x > right || pos.y < top || pos.y > bottom) continue;
|
|
const lift = getTileLift(tile);
|
|
const slope = clamp((tile.shade * .45) - light.x * .18 + light.y * .08, -.28, .32);
|
|
const shadeAlpha = clamp(alphaBase * (.55 + slope), 0, .22);
|
|
if (shadeAlpha > .012) {
|
|
ctx.fillStyle = `rgba(20, 30, 43, ${shadeAlpha.toFixed(3)})`;
|
|
drawTileDiamond(ctx, pos.x, pos.y - lift);
|
|
}
|
|
const warmAlpha = clamp((alphaBase * .7) * (.26 - slope), 0, .10);
|
|
if (!light.isNight && warmAlpha > .012) {
|
|
ctx.fillStyle = `rgba(255, 244, 194, ${warmAlpha.toFixed(3)})`;
|
|
drawTileDiamond(ctx, pos.x, pos.y - lift);
|
|
}
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawTileDiamond(c, x, y) {
|
|
c.beginPath();
|
|
c.moveTo(x, y);
|
|
c.lineTo(x + TILE_W / 2, y + TILE_H / 2);
|
|
c.lineTo(x, y + TILE_H);
|
|
c.lineTo(x - TILE_W / 2, y + TILE_H / 2);
|
|
c.closePath();
|
|
c.fill();
|
|
}
|
|
|
|
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).filter(isDrawableItemVisible);
|
|
|
|
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 isDrawableItemVisible(item) {
|
|
const pos = tileToWorld(item.x, item.y);
|
|
const margin = (item.asset?.size || 16) * (item.asset?.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE) + 36;
|
|
const sx = pos.x * view.zoom + view.x;
|
|
const sy = pos.y * view.zoom + view.y;
|
|
return sx > -margin && sy > -margin && sx < cw + margin && sy < ch + margin;
|
|
}
|
|
|
|
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 } = getSpriteDrawInfo(item, time, true);
|
|
|
|
const simpleZoom = view.zoom < 0.7;
|
|
if (!simpleZoom && !(asset.category === 'dynamic' && asset.subtype === 'fish')) drawSpriteShadow(pos, sprite, asset, 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));
|
|
if (!simpleZoom) drawSpriteShade(sprite, Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8), phase, asset);
|
|
} else {
|
|
ctx.drawImage(sprite, Math.round(drawX), Math.round(drawY));
|
|
if (!simpleZoom) drawSpriteShade(sprite, Math.round(drawX), Math.round(drawY), phase, asset);
|
|
}
|
|
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 wx = drawX + (light.x + 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(pos, sprite, asset, phase) {
|
|
if (!phase) return;
|
|
const shadow = phase?.shadow || { x: 5, y: 4, alpha: .16, length: 1 };
|
|
const silhouette = getShadowCanvas(sprite);
|
|
const angle = Math.atan2(shadow.y, shadow.x);
|
|
ctx.save();
|
|
ctx.globalAlpha = shadow.alpha;
|
|
ctx.translate(pos.x + shadow.x, pos.y + TILE_H * .58 + shadow.y);
|
|
ctx.rotate(angle * .28);
|
|
ctx.transform(1 + shadow.length * .22, 0, -shadow.x * .018, -0.30, 0, 0);
|
|
ctx.drawImage(silhouette, Math.round(-sprite.width / 2), -sprite.height);
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawSpriteShade(sprite, x, y, phase, asset) {
|
|
const light = phase?.light;
|
|
if (!light || asset?.subtype === 'water') return;
|
|
const shadeAlpha = clamp((light.shadeAlpha || .12) * (asset?.category === 'dynamic' ? .78 : 1), .04, light.isNight ? .20 : .16);
|
|
const shade = getSpriteShadeCanvas(sprite, light.x >= 0 ? 'left' : 'right', Math.round(shadeAlpha * 100));
|
|
ctx.drawImage(shade, x, y);
|
|
}
|
|
|
|
function getSpriteShadeCanvas(sprite, side, alphaBucket) {
|
|
let cache = spriteShadeCache.get(sprite);
|
|
if (!cache) {
|
|
cache = new Map();
|
|
spriteShadeCache.set(sprite, cache);
|
|
}
|
|
const key = `${side}:${alphaBucket}`;
|
|
if (cache.has(key)) return cache.get(key);
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = sprite.width;
|
|
canvas.height = sprite.height;
|
|
const c = canvas.getContext('2d');
|
|
c.imageSmoothingEnabled = false;
|
|
c.drawImage(sprite, 0, 0);
|
|
c.globalCompositeOperation = 'source-in';
|
|
const alpha = alphaBucket / 100;
|
|
const gradient = c.createLinearGradient(0, 0, sprite.width, 0);
|
|
if (side === 'left') {
|
|
gradient.addColorStop(0, `rgba(16, 22, 34, ${alpha})`);
|
|
gradient.addColorStop(.68, 'rgba(16, 22, 34, 0)');
|
|
} else {
|
|
gradient.addColorStop(.32, 'rgba(16, 22, 34, 0)');
|
|
gradient.addColorStop(1, `rgba(16, 22, 34, ${alpha})`);
|
|
}
|
|
c.fillStyle = gradient;
|
|
c.fillRect(0, 0, sprite.width, sprite.height);
|
|
cache.set(key, canvas);
|
|
return canvas;
|
|
}
|
|
|
|
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.drawImage(sprite, 0, 0);
|
|
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;
|
|
const outline = asset.meta?.outlineColor;
|
|
if (outline) {
|
|
c.fillStyle = colorToHex(outline);
|
|
const outlineReach = 0.2;
|
|
for (let y = 0; y < asset.size; y++) {
|
|
for (let x = 0; x < asset.size; x++) {
|
|
if (!pixels[y * asset.size + x]) continue;
|
|
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
|
|
const nx = x + dx, ny = y + dy;
|
|
if (nx < 0 || ny < 0 || nx >= asset.size || ny >= asset.size || !pixels[ny * asset.size + nx]) {
|
|
c.fillRect((x + dx * outlineReach) * scale, (y + dy * outlineReach) * scale, scale, scale);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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(x * scale, y * scale, scale, scale);
|
|
}
|
|
}
|
|
spriteCache.set(key, canvas);
|
|
return canvas;
|
|
}
|
|
|
|
function clearSpriteCaches() {
|
|
spriteCache.clear();
|
|
spriteShadeCache = new WeakMap();
|
|
shadowCanvasCache = new WeakMap();
|
|
}
|
|
|
|
function getAssetPixels(asset, side = 'right') {
|
|
if (asset.category === 'dynamic') {
|
|
if (side === 'left') return normalizePixels(asset.faces?.left || mirrorPixels(normalizePixels(asset.faces?.right || asset.pixels || [], asset.size), asset.size), asset.size);
|
|
return normalizePixels(asset.faces?.right || asset.pixels || blankPixels(asset.size), asset.size);
|
|
}
|
|
return normalizePixels(asset.pixels || blankPixels(asset.size), asset.size);
|
|
}
|
|
|
|
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;
|
|
const worldPos = tileToWorld(x, y);
|
|
tiles.push({ x, y, worldPos, 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 max = tileToWorld(WORLD_W + 1, WORLD_H + 1);
|
|
const width = ORIGIN_X + WORLD_W * TILE_W / 2 + 160;
|
|
const height = max.y + TILE_H * 4;
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = Math.ceil(width);
|
|
canvas.height = Math.ceil(height);
|
|
const c = canvas.getContext('2d');
|
|
c.imageSmoothingEnabled = false;
|
|
c.fillStyle = '#74c8e4';
|
|
c.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
for (const tile of worldData.tiles) {
|
|
drawTerrainTile(c, tile, worldData);
|
|
}
|
|
return { canvas, bounds: { x: 0, y: 0, w: canvas.width, h: canvas.height } };
|
|
}
|
|
|
|
function drawTerrainTile(c, tile, worldData) {
|
|
const { x, y } = tile.worldPos || 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 = isVisibleHighlandFace(tile, frontLeft, 'left', worldData);
|
|
const rightLower = isVisibleHighlandFace(tile, frontRight, 'right', worldData);
|
|
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 = getTerrainBorderColor(tile.type);
|
|
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 getTerrainBorderColor(type) {
|
|
return {
|
|
water: '#69bbdc',
|
|
sand: '#d8c98f',
|
|
grass: '#7fbd70',
|
|
highland: '#879b72'
|
|
}[type] || '#7fbd70';
|
|
}
|
|
|
|
function isVisibleHighlandFace(tile, neighbor, side, worldData) {
|
|
if (neighbor?.type === 'highland') return false;
|
|
if (!neighbor || neighbor.type === 'water') return true;
|
|
const frontNeighbor = side === 'left' ? worldData.get(tile.x, tile.y + 2) : worldData.get(tile.x + 2, tile.y);
|
|
return !frontNeighbor || frontNeighbor.type !== 'highland';
|
|
}
|
|
|
|
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);
|
|
return normalizeState(parsed);
|
|
}
|
|
} catch (error) {
|
|
console.warn('Could not load local state.', error);
|
|
}
|
|
return seedState();
|
|
}
|
|
|
|
function saveState() {
|
|
clearTimeout(saveTimer);
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
}
|
|
|
|
function scheduleSaveState(delay = 350) {
|
|
clearTimeout(saveTimer);
|
|
saveTimer = setTimeout(saveState, delay);
|
|
}
|
|
|
|
function normalizeState(input) {
|
|
const fallback = seedState();
|
|
if (!input || !Array.isArray(input.assets)) return fallback;
|
|
const normalized = {
|
|
version: 6,
|
|
authorName: input.authorName || 'Local Artist',
|
|
assets: input.assets.map(normalizeAsset),
|
|
placed: dedupeByAsset(Array.isArray(input.placed) ? input.placed : []),
|
|
dynamicSummons: dedupeByAsset(Array.isArray(input.dynamicSummons) ? input.dynamicSummons : []),
|
|
objectVotes: input.objectVotes || {},
|
|
assetVotes: input.assetVotes || {},
|
|
hiddenAssets: input.hiddenAssets || {},
|
|
hiddenObjects: input.hiddenObjects || {},
|
|
settings: {
|
|
lightingEnabled: input.settings?.lightingEnabled !== false
|
|
}
|
|
};
|
|
normalized.placed = normalized.placed.map((placed) => {
|
|
const asset = normalized.assets.find((a) => a.id === placed.assetId);
|
|
if (!asset) return placed;
|
|
const tile = world.get(placed.x, placed.y);
|
|
if (asset.subtype === 'water' && tile?.type !== 'water') {
|
|
return { ...placed, ...findNearestTerrain('water', placed.x, placed.y) };
|
|
}
|
|
return placed;
|
|
});
|
|
normalized.dynamicSummons = normalized.dynamicSummons.map((summon) => {
|
|
const asset = normalized.assets.find((a) => a.id === summon.assetId);
|
|
if (!asset) return summon;
|
|
const tile = world.get(Math.round(summon.homeX), Math.round(summon.homeY));
|
|
if (asset.subtype === 'fish' && tile?.type !== 'water') {
|
|
return { ...summon, ...homeFromPos(findNearestTerrain('water', Math.round(summon.homeX), Math.round(summon.homeY))) };
|
|
}
|
|
return summon;
|
|
});
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeAsset(asset) {
|
|
const size = Number(asset.size) || 16;
|
|
const category = asset.category === 'dynamic' ? 'dynamic' : 'static';
|
|
const base = normalizePixels(asset.pixels || asset.faces?.right || blankPixels(size), size);
|
|
const right = normalizePixels(asset.faces?.right || base, size);
|
|
const left = normalizePixels(asset.faces?.left || mirrorPixels(right, size), size);
|
|
return {
|
|
id: asset.id || uid(),
|
|
name: asset.name || 'Untitled',
|
|
category,
|
|
subtype: asset.subtype || (category === 'dynamic' ? 'human' : 'other'),
|
|
size,
|
|
pixels: encodePixels(base),
|
|
faces: category === 'dynamic' ? { right: encodePixels(right), left: encodePixels(left) } : null,
|
|
parentAssetId: asset.parentAssetId || null,
|
|
originalAssetId: asset.originalAssetId || null,
|
|
createdAt: asset.createdAt || Date.now(),
|
|
author: asset.author || 'Local Artist',
|
|
meta: {
|
|
hasLight: Boolean(asset.meta?.hasLight || (asset.meta?.lightPixels || []).length),
|
|
lightPixels: Array.isArray(asset.meta?.lightPixels) ? asset.meta.lightPixels.map((p) => ({ x: Number(p.x), y: Number(p.y), c: p.c || asset.meta?.lightColor || nearestPaletteCode('#ffd86a') })).filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) : [],
|
|
lightColor: nearestPaletteCode(asset.meta?.lightColor || '#ffd86a'),
|
|
outlineColor: asset.meta?.outlineColor ? nearestPaletteCode(asset.meta.outlineColor) : null,
|
|
door: asset.meta?.door || null
|
|
}
|
|
};
|
|
}
|
|
|
|
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 {
|
|
version: 6,
|
|
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: {},
|
|
settings: { lightingEnabled: true },
|
|
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 left = alignPixelsToBottom(normalizePixels(leftPixels || mirrorPixels(right, size), size), size);
|
|
return {
|
|
id,
|
|
name,
|
|
category,
|
|
subtype,
|
|
size,
|
|
pixels: encodePixels(right),
|
|
faces: category === 'dynamic' ? { right: encodePixels(right), left: encodePixels(left) } : null,
|
|
parentAssetId: null,
|
|
originalAssetId: null,
|
|
createdAt: Date.now(),
|
|
author: 'Island Team',
|
|
meta: {
|
|
hasLight: Boolean(meta.hasLight),
|
|
lightPixels: (meta.lightPixels || []).map((p) => ({ ...p, c: p.c || nearestPaletteCode(meta.lightColor || '#ffd86a') })),
|
|
lightColor: nearestPaletteCode(meta.lightColor || '#ffd86a'),
|
|
outlineColor: meta.outlineColor ? nearestPaletteCode(meta.outlineColor) : null,
|
|
door: meta.door || null
|
|
}
|
|
};
|
|
}
|
|
|
|
|
|
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('JSON exported.');
|
|
}
|
|
|
|
function importData() {
|
|
try {
|
|
const imported = JSON.parse(els.dataBox.value);
|
|
state = normalizeState(imported);
|
|
hydrateAuthorUI();
|
|
selectedAssetId = state.assets[0]?.id ?? null;
|
|
saveState();
|
|
clearSpriteCaches();
|
|
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();
|
|
hydrateAuthorUI();
|
|
selectedAssetId = state.assets[0]?.id ?? null;
|
|
saveState();
|
|
clearSpriteCaches();
|
|
hydrateRuntime();
|
|
renderLibrary();
|
|
updateSelectedLabel();
|
|
toast('Local world reset.');
|
|
}
|
|
|
|
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 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();
|
|
})();
|