This commit is contained in:
33333-33333 2026-06-02 17:19:36 +09:00
commit 94571943cf
23 changed files with 817 additions and 5563 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
*.bak
__pycache__/
*.py[cod]

View file

@ -6,6 +6,14 @@ Browser-only prototype for a shared pixel-art island.
Open `index.html` in a modern browser. No build step or third-party dependency is required.
## Refactor layout
- `app.js` remains the browser bootstrap and main UI coordinator.
- `js/core-utils.js`, `js/state-index.js`, `js/rotation-policy.js`, and `js/lighting.js` hold reusable logic shared by the app.
- `js/editor-actions.js` is lazy-loaded when Pixel Studio opens; the app keeps inline fallbacks for first-use safety.
- Server rotation defaults can be supplied with `server/rotation_policy.json` and `--config`.
- Server policy tests live in `server/test_rotation_worker.py`.
## Current prototype scope
- Draw pixel assets in the in-browser editor.
@ -269,3 +277,7 @@ Use this to compare minified JSON, gzip, zlib, and Brotli when available. Produc
- Replaced the numeric clock with a fixed analog day-night clock using a sky-color conic gradient and no numbers.
- Kept selected-object shadows mirrored from the ground line while the sprite jumps.
- Smoothed sky and overlay interpolation across dawn, day, evening, and night.
## v16b hotfix
- Fixed the dark/blank world regression caused by using `source-in` compositing directly on the main world canvas for ship water reflection.
- Ship reflections are now precomposited on an offscreen canvas before drawing, so terrain and sprites are not erased or darkened.

View file

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

428
app.js
View file

@ -20,16 +20,22 @@
const VIEW_CULL_MARGIN = 192;
const DYNAMIC_LOGIC_STEP_MS = 1000 / 15;
const MAX_DYNAMIC_STEPS_PER_FRAME = 3;
const MAX_SPRITE_CACHE_ENTRIES = 260;
const COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const PALETTE = buildPalette();
const PALETTE_BY_CODE = Object.fromEntries(PALETTE.map((p) => [p.code, p.color]));
const MODULES = window.PixelIslandModules || {};
const EditorActions = MODULES.EditorActions || null;
const Phase2Sync = MODULES.Phase2Sync || null;
const StateIndex = MODULES.StateIndex || null;
const RotationPolicy = MODULES.RotationPolicy || null;
const Lighting = MODULES.Lighting || null;
const ModuleLoader = MODULES.ModuleLoader || null;
const EDITOR_HISTORY_LIMIT = 80;
const PHASE5_GUARDRAILS = { maxAssets: 220, maxWorldObjects: 1000, maxReports: 200, defaultDisplayLimit: 250, newArrivalSlots: 150, revivalSlots: 100, publishLimitFirstDay: 5, publishLimitTrusted: 10, upvoteDelaySlots: 20, downvoteAdvanceSlots: 25, upvoteRankCap: 50, maxParticles: 200, particleMinZoom: 0.72 };
const editorActions = () => MODULES.EditorActions || null;
const $ = (id) => document.getElementById(id);
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const uid = () => Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-5);
@ -143,6 +149,7 @@
let world = makeWorld();
let terrainCache = buildTerrainCache(world);
let terrainFrontCache = buildTerrainFrontCache(world);
let state = loadState();
let selectedAssetId = state.assets[0]?.id ?? null;
let mode = 'inspect';
@ -154,6 +161,7 @@
let hoverTile = null;
let dynamicRuntime = [];
let spriteCache = new Map();
let lastRenderedSpriteInfo = new Map();
let lastRuntimeUpdate = performance.now();
let lastClockSecond = -1;
let toastTimer = null;
@ -186,6 +194,7 @@
let coastalFoamTextures = null;
let mousePaint = { active: false, panning: false, lastX: 0, lastY: 0, button: 0 };
let shadowCanvasCache = new WeakMap();
let shipReflectionCanvasCache = new WeakMap();
let animationFrameId = 0;
let dynamicLogicRemainder = 0;
let worldIndex = makeEmptyWorldIndex();
@ -210,6 +219,7 @@
function bootstrap() {
resizeCanvas();
resetView(false);
rebuildWorldIndex();
hydrateRuntime();
coastalFoamTextures = buildCoastalFoamTextures();
wireUI();
@ -221,7 +231,6 @@
clearEditorHistory();
renderLibrary();
updateSelectedLabel();
rebuildWorldIndex();
updateSyncStats();
updateGuardrailStats();
hydrateVisualSettingsUI();
@ -478,12 +487,19 @@
function setDrawerOpen(open) {
els.drawer.classList.toggle('open', open);
els.openEditor.hidden = open;
if (open) ensureEditorHelpers();
if (!open) {
selectedObject = null;
updateSelectionBubble(performance.now());
}
}
function ensureEditorHelpers() {
if (editorActions() || !ModuleLoader?.loadScript) return;
ModuleLoader.loadScript('./js/editor-actions.js')
.catch((error) => console.warn('Editor helper lazy load failed; using inline fallbacks.', error));
}
function onDocumentContextMenu(event) {
if (!els.drawer?.classList.contains('open')) return;
if (els.drawer.contains(event.target) || els.openEditor?.contains(event.target) || els.openCreate?.contains(event.target) || els.openCollection?.contains(event.target) || els.openMenu?.contains(event.target)) return;
@ -754,7 +770,7 @@
}
function makeEmptyWorldIndex() {
return { placedByTile: new Map(), dynamicByHomeTile: new Map() };
return StateIndex?.build ? StateIndex.build({ assets: [], placed: [], dynamicSummons: [] }) : { assetById: new Map(), staticById: new Map(), dynamicById: new Map(), objectsById: new Map(), objectIdsByAssetId: new Map(), placedByTile: new Map(), dynamicByHomeTile: new Map() };
}
function pushIndexBucket(map, key, item) {
@ -764,12 +780,25 @@
}
function rebuildWorldIndex() {
if (StateIndex?.build) {
worldIndex = StateIndex.build(state);
return;
}
const next = makeEmptyWorldIndex();
for (const asset of state.assets || []) {
if (asset?.id) next.assetById.set(asset.id, asset);
}
for (const placed of state.placed || []) {
next.staticById.set(placed.id, placed);
next.objectsById.set(placed.id, { kind: 'static', object: placed });
pushIndexBucket(next.placedByTile, tileKey(placed.x, placed.y), placed);
pushIndexBucket(next.objectIdsByAssetId, placed.assetId, placed.id);
}
for (const summon of state.dynamicSummons || []) {
next.dynamicById.set(summon.id, summon);
next.objectsById.set(summon.id, { kind: 'dynamic', object: summon });
pushIndexBucket(next.dynamicByHomeTile, tileKey(Math.round(summon.homeX), Math.round(summon.homeY)), summon);
pushIndexBucket(next.objectIdsByAssetId, summon.assetId, summon.id);
}
worldIndex = next;
}
@ -789,12 +818,12 @@
}
function getObjectRecord(kind, objectId) {
if (kind === 'static') return state.placed.find((p) => p.id === objectId) || null;
return state.dynamicSummons.find((p) => p.id === objectId) || null;
if (kind === 'static') return worldIndex.staticById?.get(objectId) || state.placed.find((p) => p.id === objectId) || null;
return worldIndex.dynamicById?.get(objectId) || state.dynamicSummons.find((p) => p.id === objectId) || null;
}
function getObjectPublicAt(kind, object) {
return Number(object?.publishedAt || object?.placedAt || object?.createdAt || Date.now());
return RotationPolicy?.objectPublicAt ? RotationPolicy.objectPublicAt(object) : Number(object?.publishedAt || object?.placedAt || object?.createdAt || Date.now());
}
function isPermanentlyHiddenObject(object) {
@ -804,8 +833,12 @@
function isModeratedAssetHidden(asset) {
if (!asset) return false;
if (asset.status === 'permanent_hidden' || asset.status === 'violation_hidden') return true;
const allObjects = [...(state.placed || []), ...(state.dynamicSummons || [])];
return allObjects.some((object) => object.assetId === asset.id && isPermanentlyHiddenObject(object) && object.hiddenReason === 'moderation_violation');
const objectIds = worldIndex.objectIdsByAssetId?.get(asset.id) || [];
for (const objectId of objectIds) {
const entry = worldIndex.objectsById?.get(objectId);
if (entry?.object && isPermanentlyHiddenObject(entry.object) && entry.object.hiddenReason === 'moderation_violation') return true;
}
return false;
}
function isServerSuppressedObject(object) {
@ -813,25 +846,12 @@
}
function getObjectRotationEntry(kind, object, baseIndex = 0) {
const objectId = object?.id;
const votes = getObjectVoteCounts(objectId);
const votes = getObjectVoteCounts(object?.id);
if (RotationPolicy?.entry) return RotationPolicy.entry(kind, object, baseIndex, votes, PHASE5_GUARDRAILS);
const rawUp = Number(votes.up) || 0;
const up = Math.min(rawUp, PHASE5_GUARDRAILS.upvoteRankCap);
const down = Number(votes.down) || 0;
const delay = up * PHASE5_GUARDRAILS.upvoteDelaySlots;
const advance = down * PHASE5_GUARDRAILS.downvoteAdvanceSlots;
return {
kind,
object,
id: objectId,
assetId: object?.assetId,
publicAt: getObjectPublicAt(kind, object),
baseIndex,
up,
rawUp,
down,
effectiveSlot: baseIndex + delay - advance
};
return { kind, object, id: object?.id, assetId: object?.assetId, publicAt: getObjectPublicAt(kind, object), baseIndex, up, rawUp, down, effectiveSlot: baseIndex + up * PHASE5_GUARDRAILS.upvoteDelaySlots - down * PHASE5_GUARDRAILS.downvoteAdvanceSlots };
}
function getRotationEntries(includeLocalHidden = false) {
@ -853,6 +873,7 @@
}
function seededScore(id, salt = '') {
if (RotationPolicy?.seededScore) return RotationPolicy.seededScore(id, salt, state.lastRotationAt || Date.now());
const hash = fnv1a(`${id}|${salt}|${Math.floor((state.lastRotationAt || Date.now()) / (24 * 60 * 60 * 1000))}`);
return parseInt(hash.slice(0, 8), 16) / 0xffffffff;
}
@ -860,6 +881,7 @@
function getIslandDisplayBuckets() {
const entries = getRotationEntries(false);
const localLimit = getLocalDisplayLimit();
if (RotationPolicy?.buckets) return RotationPolicy.buckets(entries, localLimit, PHASE5_GUARDRAILS, state.lastRotationAt || Date.now());
const newCap = Math.min(PHASE5_GUARDRAILS.newArrivalSlots, localLimit);
const revivalCap = Math.max(0, Math.min(PHASE5_GUARDRAILS.revivalSlots, localLimit - newCap));
const newest = entries
@ -904,6 +926,7 @@
function currentPublishLimit(now = Date.now()) {
if (!state.account?.createdAt) return 0;
if (RotationPolicy?.publishLimit) return RotationPolicy.publishLimit(state.account, now, PHASE5_GUARDRAILS);
return getAccountAgeMs(now) < 24 * 60 * 60 * 1000
? PHASE5_GUARDRAILS.publishLimitFirstDay
: PHASE5_GUARDRAILS.publishLimitTrusted;
@ -1473,8 +1496,9 @@
if (paintTool === 'fill') {
const displayPixels = getDisplayEditorPixels();
const fillColor = shiftKey ? null : selectedColorCode;
const nextDisplay = EditorActions?.floodFill
? EditorActions.floodFill(displayPixels, editorSize, x, y, fillColor)
const actions = editorActions();
const nextDisplay = actions?.floodFill
? actions.floodFill(displayPixels, editorSize, x, y, fillColor)
: fallbackFloodFill(displayPixels, editorSize, x, y, fillColor);
if (nextDisplay.changed) {
editorPixels = nextDisplay.pixels;
@ -2560,6 +2584,7 @@
editParentId = null;
editOriginalId = null;
editingAssetId = null;
rebuildWorldIndex();
saveState();
spriteCache.clear();
hydrateRuntime();
@ -2818,7 +2843,8 @@
function makeAssetCard(asset) {
const card = document.createElement('article');
card.className = `assetCard${asset.id === selectedAssetId ? ' selected' : ''}`;
const expanded = asset.id === selectedAssetId;
card.className = `assetCard${expanded ? ' selected expanded' : ''}`;
const preview = document.createElement('canvas');
preview.className = 'assetPreview';
@ -2828,53 +2854,60 @@
const meta = document.createElement('div');
meta.className = 'assetMeta';
const lineage = asset.parentAssetId ? ' / derivative' : '';
meta.innerHTML = `<strong></strong><span>${displayCategory(asset)} / ${cap(asset.subtype)} / ${asset.size}×${asset.size}${lineage}</span><span></span>`;
meta.querySelector('strong').textContent = asset.name;
meta.querySelectorAll('span')[1].textContent = `Author: ${asset.author || 'Local Artist'} · Remixed: ${getRemixCount(asset.id)}`;
const title = document.createElement('strong');
title.textContent = asset.name;
meta.append(title);
card.addEventListener('click', () => {
selectedAssetId = asset.id;
selectedAssetId = expanded ? null : asset.id;
updateSelectedLabel();
renderLibrary();
focusAssetInWorld(asset);
if (selectedAssetId) focusAssetInWorld(asset);
});
const actions = document.createElement('div');
actions.className = 'assetActions';
const assetVotes = getAssetVoteCounts(asset.id);
const assetPreviousVote = assetVotes.voters?.[currentVoterKey()] || 0;
const up = makeButton(`${assetVotes.up}`, (event) => { event.stopPropagation(); voteAsset(asset.id, 1); });
const down = makeButton(`${assetVotes.down}`, (event) => { event.stopPropagation(); voteAsset(asset.id, -1); });
up.classList.toggle('activeVote', assetPreviousVote > 0);
down.classList.toggle('activeVote', assetPreviousVote < 0);
up.classList.toggle('mutedVote', assetPreviousVote < 0);
down.classList.toggle('mutedVote', assetPreviousVote > 0);
const hide = makeButton('Hide', (event) => { event.stopPropagation(); hideAsset(asset.id); });
hide.classList.toggle('mutedAction', assetPreviousVote >= 0);
const isMine = (asset.author || 'Local Artist') === (state.authorName || 'Local Artist');
const remix = makeButton('Remix', (event) => {
event?.stopPropagation?.();
remixEdit(asset);
});
const move = isMine ? makeButton('Place/Move', (event) => {
event?.stopPropagation?.();
selectedAssetId = asset.id;
updateSelectedLabel();
renderLibrary();
setMode('place');
setDrawerOpen(false);
toast('Click a valid tile to place or move it.');
}) : null;
const del = makeButton('Delete', (event) => {
event?.stopPropagation?.();
deleteAsset(asset);
});
del.classList.add('danger');
actions.append(up, down, hide, remix);
if (move) actions.append(move);
if (isMine) actions.append(del);
meta.append(actions);
if (expanded) {
const lineage = asset.parentAssetId ? ' / derivative' : '';
const span1 = document.createElement('span');
span1.textContent = `${displayCategory(asset)} / ${cap(asset.subtype)} / ${asset.size}×${asset.size}${lineage}`;
const span2 = document.createElement('span');
span2.textContent = `Author: ${asset.author || 'Local Artist'} · Remixed: ${getRemixCount(asset.id)}`;
meta.append(span1, span2);
const actions = document.createElement('div');
actions.className = 'assetActions';
const assetVotes = getAssetVoteCounts(asset.id);
const assetPreviousVote = assetVotes.voters?.[currentVoterKey()] || 0;
const up = makeButton(`${assetVotes.up}`, (event) => { event.stopPropagation(); voteAsset(asset.id, 1); });
const down = makeButton(`${assetVotes.down}`, (event) => { event.stopPropagation(); voteAsset(asset.id, -1); });
up.classList.toggle('activeVote', assetPreviousVote > 0);
down.classList.toggle('activeVote', assetPreviousVote < 0);
up.classList.toggle('mutedVote', assetPreviousVote < 0);
down.classList.toggle('mutedVote', assetPreviousVote > 0);
const hide = makeButton('Hide', (event) => { event.stopPropagation(); hideAsset(asset.id); });
hide.classList.toggle('mutedAction', assetPreviousVote >= 0);
const isMine = (asset.author || 'Local Artist') === (state.authorName || 'Local Artist');
const remix = makeButton('Remix', (event) => { event?.stopPropagation?.(); remixEdit(asset); });
const move = isMine ? makeButton('Place/Move', (event) => {
event?.stopPropagation?.();
selectedAssetId = asset.id;
updateSelectedLabel();
renderLibrary();
setMode('place');
setDrawerOpen(false);
toast('Click a valid tile to place or move it.');
}) : null;
const del = makeButton('Delete', (event) => { event?.stopPropagation?.(); deleteAsset(asset); });
del.classList.add('danger');
actions.append(up, down, hide, remix);
if (move) actions.append(move);
if (isMine) actions.append(del);
meta.append(actions);
} else {
const mini = document.createElement('span');
mini.textContent = `${cap(asset.subtype)} · ${asset.size}×${asset.size}`;
meta.append(mini);
}
card.append(preview, meta);
return card;
}
@ -3381,6 +3414,7 @@
}
function getShadowForMinute(minute) {
if (Lighting?.getShadowForMinute) return Lighting.getShadowForMinute(minute);
const m = clamp(Number(minute) || 0, 0, 10);
const smooth = (t) => clamp(t, 0, 1) * clamp(t, 0, 1) * (3 - 2 * clamp(t, 0, 1));
let dirX = -0.9;
@ -3449,6 +3483,7 @@
}
function getPhase() {
if (Lighting?.getPhase) return Lighting.getPhase({ dayMs: DAY_MS, now: Date.now(), dayNightEnabled: visualSettings().enableDayNight !== false, mixHex });
if (visualSettings().enableDayNight === false) {
return { key: 'day', label: 'Day', progress: 0.25, sky: '#86d5ff', darkness: 0, darkOverlay: 'rgba(12, 19, 45, 0)', tint: 'rgba(255,255,255,0)', tintAlpha: 0, shadow: getShadowForMinute(3) };
}
@ -3544,6 +3579,7 @@
ctx.imageSmoothingEnabled = false;
drawTerrainCache(terrainCache);
drawHoverTile();
lastRenderedSpriteInfo.clear();
const lightSources = [];
const visibleItems = drawObjects(time, lightSources, phase);
if (visualSettings().enableParticles) {
@ -3554,6 +3590,7 @@
drawSpawnEffects(time);
drawConfettiParticles(time);
}
drawTerrainFrontCache(terrainFrontCache);
const nightLightsActive = areNightLightsActive(phase);
ctx.restore();
@ -3632,10 +3669,23 @@
const applyTileLift = !(asset.category === 'dynamic' && asset.subtype === 'bird');
if (applyTileLift) pos.y -= getLiftAtCoord(item.x, item.y);
let bob = 0;
let stretchX = 1;
let stretchY = 1;
let alpha = 1;
let side = 'right';
let angle = 0;
if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship')) bob += Math.sin(time / 900 + item.x * .7) * 1.6;
if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship')) {
bob += Math.sin(time / 900 + item.x * .7) * 1.6;
if (asset.subtype === 'ship') {
const drift = Math.sin(time / 2400 + item.x * 0.37 + item.y * 0.19) * 5.2;
const glide = Math.cos(time / 3200 + item.x * 0.21) * 2.6;
pos.x += drift;
pos.y += glide * 0.22;
angle += Math.sin(time / 1400 + item.x * 0.2) * 0.035;
}
}
if (asset.category === 'dynamic') {
const runtime = item.source || {};
const seed = Number.isFinite(runtime.seed) ? runtime.seed : 0;
@ -3653,32 +3703,41 @@
bob = -hop * (2.2 + terrainBoost * 1.8 + (liftNow > 0 ? 0.6 : 0));
const cycle = Math.floor(jumpPhase / (Math.PI * 0.9));
const rand = pseudoNoise(cycle + seed * 13.17);
angle = (rand - 0.5) * 0.24 * hop;
angle += (rand - 0.5) * 0.24 * hop;
const land = Math.pow(Math.max(0, Math.cos(jumpPhase * 2)), 10) * Math.max(0, 1 - hop);
stretchX += land * 0.18;
stretchY -= land * 0.12;
}
}
if (asset.subtype === 'bird') bob = -10 - Math.sin(time / 250 + seed) * 4.5;
if (asset.subtype === 'fish') { alpha = .42; bob = 4 + Math.sin(time / 380 + seed) * 1.7; }
}
if (includeSelectBounce && selectedObject?.id === item.source?.id) {
const selectedT = clamp((time - (selectedObject.selectedAt || 0)) / 560, 0, 1);
if (selectedT < 1) {
const bounce = Math.sin(selectedT * Math.PI);
bob += -bounce * 8;
bob += -bounce * (asset.category === 'dynamic' ? 9 : 8);
const tiltSeed = parseInt(fnv1a(`${item.source?.id || asset.id}:select`).slice(0, 6), 16) || 1;
const tiltSign = pseudoNoise(tiltSeed) > 0.5 ? 1 : -1;
angle += tiltSign * bounce * 0.18;
const tiltRand = pseudoNoise(tiltSeed * 0.013) - 0.5;
angle += tiltRand * 0.32 * bounce;
const land = Math.exp(-Math.pow((selectedT - 0.92) / 0.09, 2));
stretchX += land * 0.26;
stretchY -= land * 0.18;
}
}
const sprite = getSpriteCanvas(asset, side);
const drawX = pos.x - sprite.width / 2;
const anchorY = asset.category === 'static' ? 0 : TILE_H / 2;
const drawY = pos.y + anchorY - sprite.height + bob;
return { asset, pos, sprite, drawX, drawY, alpha, angle, side, bob, anchorY };
return { asset, pos, sprite, drawX, drawY, alpha, angle, side, bob, anchorY, stretchX, stretchY };
}
function drawSpriteItem(item, time, lightSources, underwater, phase) {
const info = getSpriteDrawInfo(item, time, true);
const { asset, pos, sprite, drawX, drawY, alpha, angle } = info;
const { asset, pos, sprite, drawX, drawY, alpha, angle, stretchX = 1, stretchY = 1 } = info;
lastRenderedSpriteInfo.set(item.source?.id || asset.id, { ...info, time });
if (visualSettings().enableParticles && asset.category === 'static' && asset.subtype === 'ship') drawShipRipples(pos, time, item.source?.id || asset.id);
@ -3686,16 +3745,19 @@
ctx.save();
ctx.globalAlpha = alpha;
if (angle) {
ctx.translate(Math.round(drawX + sprite.width / 2), Math.round(drawY + sprite.height * 0.8));
ctx.rotate(angle);
const pivotX = Math.round(drawX + sprite.width / 2);
const pivotY = Math.round(drawY + sprite.height * 0.8);
if (angle || Math.abs(stretchX - 1) > 0.001 || Math.abs(stretchY - 1) > 0.001) {
ctx.translate(pivotX, pivotY);
if (angle) ctx.rotate(angle);
if (Math.abs(stretchX - 1) > 0.001 || Math.abs(stretchY - 1) > 0.001) ctx.scale(stretchX, stretchY);
ctx.drawImage(sprite, Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8));
} else {
ctx.drawImage(sprite, Math.round(drawX), Math.round(drawY));
}
if (underwater) {
ctx.fillStyle = 'rgba(103, 181, 217, .18)';
if (angle) ctx.fillRect(Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8), sprite.width, sprite.height);
if (angle || Math.abs(stretchX - 1) > 0.001 || Math.abs(stretchY - 1) > 0.001) ctx.fillRect(Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8), sprite.width, sprite.height);
else ctx.fillRect(Math.round(drawX), Math.round(drawY), sprite.width, sprite.height);
}
ctx.restore();
@ -3920,22 +3982,30 @@
function drawSpriteShadow(info, phase) {
const { drawX, sprite, pos, anchorY, bob } = info;
const { drawX, sprite, pos, anchorY, bob, stretchX = 1, stretchY = 1, asset } = info;
const shadow = phase?.shadow || { dirX: 0, length: 1, skewX: 0, scaleY: 0.28, alpha: .16 };
if ((shadow.alpha || 0) <= 0.001) return;
const silhouette = getShadowCanvas(sprite);
const contactX = drawX + sprite.width / 2;
// Keep the mirror line on the ground. When a selected sprite jumps upward,
// the reflected silhouette moves downward by the same amount instead of floating.
const groundY = pos.y + anchorY;
const mirrorOffset = Math.max(0, -(bob || 0));
ctx.save();
ctx.globalAlpha = shadow.alpha;
ctx.translate(contactX, groundY);
ctx.transform(1 + shadow.length * 0.08, 0, shadow.skewX, shadow.scaleY, 0, 0);
ctx.transform((1 + shadow.length * 0.08) * stretchX, 0, shadow.skewX, shadow.scaleY * Math.max(0.88, stretchY), 0, 0);
ctx.drawImage(silhouette, Math.round(-sprite.width / 2), Math.round(mirrorOffset));
ctx.restore();
if (asset?.subtype === 'ship') {
const reflection = getShipReflectionCanvas(sprite);
ctx.save();
ctx.globalAlpha = 0.24;
ctx.translate(contactX, groundY + 1);
ctx.transform(1.08 * stretchX, 0, shadow.skewX * 0.55, 0.36 * Math.max(0.92, stretchY), 0, 0);
ctx.drawImage(reflection, Math.round(-sprite.width / 2), Math.round(mirrorOffset + 2));
ctx.restore();
}
}
function getShadowCanvas(sprite) {
@ -3957,6 +4027,29 @@
return canvas;
}
function getShipReflectionCanvas(sprite) {
if (shipReflectionCanvasCache.has(sprite)) return shipReflectionCanvasCache.get(sprite);
const canvas = document.createElement('canvas');
canvas.width = sprite.width;
canvas.height = sprite.height;
const c = canvas.getContext('2d');
c.imageSmoothingEnabled = false;
c.save();
c.translate(0, sprite.height);
c.scale(1, -1);
c.drawImage(sprite, 0, 0);
c.restore();
c.globalCompositeOperation = 'source-in';
const refl = c.createLinearGradient(0, 0, 0, canvas.height);
refl.addColorStop(0, 'rgba(174,230,255,0.46)');
refl.addColorStop(0.55, 'rgba(92,172,214,0.20)');
refl.addColorStop(1, 'rgba(54,120,170,0.02)');
c.fillStyle = refl;
c.fillRect(0, 0, canvas.width, canvas.height);
shipReflectionCanvasCache.set(sprite, canvas);
return canvas;
}
function currentVoterKey() {
return String(state.account?.id || state.authorName || els.authorName?.value || 'Local Artist').trim().toLowerCase();
@ -4080,16 +4173,17 @@
}
function getSelectedBubbleAnchor(time = performance.now()) {
if (!selectedObject) return null;
const cached = lastRenderedSpriteInfo.get(selectedObject.id);
if (cached) {
const topPad = Math.max(18, Math.min(34, cached.sprite.height * 0.22));
return { x: cached.drawX + cached.sprite.width / 2, y: cached.drawY - topPad, asset: cached.asset, objectId: selectedObject.id };
}
const item = getSelectedDrawableItem(time);
if (!item) return null;
const info = getSpriteDrawInfo(item, time, true);
const topPad = Math.max(14, Math.min(30, info.sprite.height * 0.18));
return {
x: info.drawX + info.sprite.width / 2,
y: info.drawY - topPad,
asset: item.asset,
objectId: item.source?.id || selectedObject.id
};
const topPad = Math.max(18, Math.min(34, info.sprite.height * 0.22));
return { x: info.drawX + info.sprite.width / 2, y: info.drawY - topPad, asset: item.asset, objectId: item.source?.id || selectedObject.id };
}
function updateSelectionBubble(time) {
@ -4110,8 +4204,8 @@
return;
}
els.selectionBubble.hidden = false;
const bubbleX = clamp(sx, 96, cw - 96);
const bubbleY = clamp(sy, 28, ch - 118);
const bubbleX = clamp(sx, 112, cw - 112);
const bubbleY = clamp(sy, 96, ch - 160);
els.selectionBubble.style.left = `${Math.round(bubbleX)}px`;
els.selectionBubble.style.top = `${Math.round(bubbleY)}px`;
els.selectionBubble.style.transform = 'translate(-50%, -100%)';
@ -4383,8 +4477,14 @@
function getSpriteCanvas(asset, side) {
const lightsOn = areNightLightsActive(renderPhase);
const phaseKey = renderPhase ? `${renderPhase.key}:${Math.round((renderPhase.darkness || 0) * 10)}:${lightsOn ? 'lit' : 'unlit'}` : 'day:unlit';
const key = `${asset.id}:${side}:${asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE}:${phaseKey}`;
if (spriteCache.has(key)) return spriteCache.get(key);
const revision = asset.contentHash || asset.updatedAt || asset.createdAt || asset.pixels || asset.faces?.right || '';
const key = `${asset.id}:${revision}:${side}:${asset.size}:${asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE}:${phaseKey}`;
if (spriteCache.has(key)) {
const cached = spriteCache.get(key);
spriteCache.delete(key);
spriteCache.set(key, cached);
return cached;
}
const pixels = getAssetPixels(asset, side);
const rawDepths = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size || 1);
const depths = asset.category === 'dynamic' && side === 'left' ? mirrorDepthPixels(rawDepths, asset.size) : rawDepths;
@ -4404,10 +4504,18 @@
c.fillRect(x * scale, y * scale, scale, scale);
}
}
spriteCache.set(key, canvas);
rememberSpriteCanvas(key, canvas);
return canvas;
}
function rememberSpriteCanvas(key, canvas) {
spriteCache.set(key, canvas);
while (spriteCache.size > MAX_SPRITE_CACHE_ENTRIES) {
const oldest = spriteCache.keys().next().value;
spriteCache.delete(oldest);
}
}
function getAssetPixels(asset, side = 'right') {
const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(asset.size), asset.size);
if (asset.category === 'dynamic' && side === 'left') return mirrorPixels(right, asset.size);
@ -4534,6 +4642,49 @@
}
}
function buildTerrainFrontCache(worldData) {
const chunkSize = TERRAIN_CHUNK_SIZE;
const chunks = [];
for (let cy = 0; cy < WORLD_H; cy += chunkSize) {
for (let cx = 0; cx < WORLD_W; cx += chunkSize) {
const tiles = [];
for (let y = cy; y < Math.min(WORLD_H, cy + chunkSize); y++) {
for (let x = cx; x < Math.min(WORLD_W, cx + chunkSize); x++) {
const tile = worldData.get(x, y);
if (!tile || tile.type !== 'highland') continue;
const frontLeft = worldData.get(tile.x, tile.y + 1);
const frontRight = worldData.get(tile.x + 1, tile.y);
const leftLower = !frontLeft || frontLeft.type !== 'highland';
const rightLower = !frontRight || frontRight.type !== 'highland';
if (leftLower || rightLower) tiles.push(tile);
}
}
if (!tiles.length) continue;
const bounds = getTerrainChunkBounds(tiles);
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(bounds.w);
canvas.height = Math.ceil(bounds.h);
const c = canvas.getContext('2d', { alpha: true });
c.imageSmoothingEnabled = false;
c.save();
c.translate(-bounds.x, -bounds.y);
for (const tile of tiles) drawTerrainFrontTile(c, tile, worldData);
c.restore();
chunks.push({ canvas, x: bounds.x, y: bounds.y, w: canvas.width, h: canvas.height });
}
}
return { chunks };
}
function drawTerrainFrontCache(cache) {
if (!cache?.chunks?.length) return;
const rect = getViewportWorldRect(64);
for (const chunk of cache.chunks) {
if (chunk.x + chunk.w < rect.left || chunk.x > rect.right || chunk.y + chunk.h < rect.top || chunk.y > rect.bottom) continue;
ctx.drawImage(chunk.canvas, chunk.x, chunk.y);
}
}
function buildCoastalFoamTextures() {
return { a: makeFoamTexture(24, 1), b: makeFoamTexture(24, 2) };
@ -4591,6 +4742,35 @@
}
function drawTerrainFrontTile(c, tile, worldData) {
const { x, y } = tileToWorld(tile.x, tile.y);
const lift = getTileLift(tile);
const frontLeft = worldData.get(tile.x, tile.y + 1);
const frontRight = worldData.get(tile.x + 1, tile.y);
const leftLower = !frontLeft || frontLeft.type !== 'highland';
const rightLower = !frontRight || frontRight.type !== 'highland';
if (rightLower) {
c.fillStyle = '#7b8e6c';
c.beginPath();
c.moveTo(x, y + TILE_H - lift);
c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift);
c.lineTo(x + TILE_W / 2, y + TILE_H / 2);
c.lineTo(x, y + TILE_H);
c.closePath();
c.fill();
}
if (leftLower) {
c.fillStyle = '#6f8263';
c.beginPath();
c.moveTo(x - TILE_W / 2, y + TILE_H / 2 - lift);
c.lineTo(x, y + TILE_H - lift);
c.lineTo(x, y + TILE_H);
c.lineTo(x - TILE_W / 2, y + TILE_H / 2);
c.closePath();
c.fill();
}
}
function drawTerrainTile(c, tile, worldData) {
const { x, y } = tileToWorld(tile.x, tile.y);
const palette = {
@ -4603,33 +4783,6 @@
const fill = tile.shade > 0 ? base : alt;
const lift = getTileLift(tile);
if (tile.type === 'highland') {
const frontLeft = worldData.get(tile.x, tile.y + 1);
const frontRight = worldData.get(tile.x + 1, tile.y);
const leftLower = !frontLeft || frontLeft.type !== 'highland';
const rightLower = !frontRight || frontRight.type !== 'highland';
if (rightLower) {
c.fillStyle = '#7b8e6c';
c.beginPath();
c.moveTo(x, y + TILE_H - lift);
c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift);
c.lineTo(x + TILE_W / 2, y + TILE_H / 2);
c.lineTo(x, y + TILE_H);
c.closePath();
c.fill();
}
if (leftLower) {
c.fillStyle = '#6f8263';
c.beginPath();
c.moveTo(x - TILE_W / 2, y + TILE_H / 2 - lift);
c.lineTo(x, y + TILE_H - lift);
c.lineTo(x, y + TILE_H);
c.lineTo(x - TILE_W / 2, y + TILE_H / 2);
c.closePath();
c.fill();
}
}
c.fillStyle = fill;
c.beginPath();
c.moveTo(x, y - lift);
@ -4710,6 +4863,7 @@
function saveState() {
state.schema = SAVE_SCHEMA;
canonicalizeAssetStorage();
state.guardrails = { ...PHASE5_GUARDRAILS, ...(state.guardrails || {}) };
state.moderationReports = normalizeModerationReports(state.moderationReports || []);
state.settings = { ...defaultVisualSettings(), ...(state.settings || {}) };
@ -4743,6 +4897,18 @@
};
}
function canonicalizeAssetStorage() {
let changed = false;
state.assets = (state.assets || []).map((asset) => {
const right = asset?.faces?.right || asset?.pixels;
const needsCanonical = Array.isArray(right) || Array.isArray(asset?.pixels) || Array.isArray(asset?.meta?.depthPixels);
if (!needsCanonical) return asset;
changed = true;
return normalizeAsset(asset);
});
if (changed) rebuildWorldIndex();
}
function normalizeAsset(asset) {
const size = Number(asset.size) || 16;
const category = asset.category === 'dynamic' ? 'dynamic' : 'static';
@ -4998,6 +5164,7 @@
const byId = new Map(state.assets.map((asset) => [asset.id, asset]));
importedAssets.forEach((asset) => byId.set(asset.id, asset));
state.assets = [...byId.values()];
rebuildWorldIndex();
saveState();
spriteCache.clear();
renderLibrary();
@ -5046,6 +5213,20 @@
state.sync.lastEventId = event.id;
}
function applySyncEvent(event) {
if (!event) return false;
const unpackObject = (kind, object) => kind === 'dynamic'
? Phase2Sync?.unpackDynamic?.(object) || object
: Phase2Sync?.unpackPlacement?.(object) || object;
if (StateIndex?.reduce) StateIndex.reduce(state, event, { unpackAsset: Phase2Sync?.unpackAsset, unpackObject });
else Phase2Sync?.applyEvent?.(state, event);
rebuildWorldIndex();
spriteCache.clear();
state.sync ||= { lastEventId: null };
state.sync.lastEventId = event.id || state.sync.lastEventId;
return true;
}
function cachePhase2State() {
if (!Phase2Sync?.cacheAssets) return;
Phase2Sync.cacheAssets(state.assets || []).catch((error) => console.warn('Phase 2 asset cache failed.', error));
@ -5211,7 +5392,7 @@
}
function findAsset(id) {
return state.assets.find((asset) => asset.id === id) || null;
return worldIndex.assetById?.get(id) || state.assets.find((asset) => asset.id === id) || null;
}
function blankPixels(size) {
@ -5727,5 +5908,6 @@
}
window.PixelIslandDebug = { ...(window.PixelIslandDebug || {}), applySyncEvent };
bootstrap();
})();

3774
app.js.bak

File diff suppressed because it is too large Load diff

View file

@ -60,6 +60,7 @@
<nav class="tabs" aria-label="Studio tabs">
<button class="tab active" data-tab="draw">Create</button>
<button class="tab" data-tab="library">Collection</button>
<button class="tab" data-tab="settings">Settings</button>
<button class="tab devOnly" data-tab="data">Data</button>
</nav>
@ -67,7 +68,7 @@
<section id="tab-draw" class="tabPanel active">
<div class="card editorCard heroEditor">
<div class="quickSetupBar twoCols">
<label class="field">Canvas size
<label class="field inlineField">Canvas size
<select id="assetSize">
<option value="8" selected>8×8</option>
<option value="16">16×16</option>
@ -75,7 +76,7 @@
<option value="64">64×64</option>
</select>
</label>
<label class="field">Role
<label class="field inlineField">Role
<select id="assetCategory">
<option value="human">Human</option>
<option value="animal">Animal</option>
@ -87,7 +88,7 @@
</label>
</div>
<label class="field compactNameField">Name
<label class="field compactNameField inlineField">Name
<input id="assetName" type="text" maxlength="32" placeholder="Tiny bakery, round tree, island pup..." />
</label>
<div class="editorTop compactEditorTop">
@ -179,6 +180,22 @@
</div>
</section>
<section id="tab-settings" class="tabPanel">
<div class="card stack">
<div class="cardTitle">Settings</div>
<p class="hint">Turn major visual systems on or off. Server decides the public 250 exhibition slots; this local cap only filters your view.</p>
<div class="toggleList">
<label class="checkRow"><input id="settingLights" type="checkbox" checked /> <span>Lights & glow</span></label>
<label class="checkRow"><input id="settingParticles" type="checkbox" checked /> <span>Particles & ambient FX</span></label>
<label class="checkRow"><input id="settingDayNight" type="checkbox" checked /> <span>Day / night cycle</span></label>
<label class="field displayLimitField">Island display cap
<input id="displayLimit" type="number" min="25" max="500" step="25" value="250" />
</label>
</div>
<div id="rotationStats" class="syncStats"></div>
</div>
</section>
<section id="tab-data" class="tabPanel devOnly">
<div class="card stack">
<div class="cardTitle">Local save</div>
@ -198,21 +215,6 @@
<textarea id="dataBox" rows="10" placeholder="Exported JSON appears here."></textarea>
</div>
<div class="card stack">
<div class="cardTitle">Visual settings</div>
<p class="hint">Turn major visual systems on or off. Server decides the public 250 exhibition slots; this local cap only filters your view.</p>
<div class="toggleList">
<label class="checkRow"><input id="settingLights" type="checkbox" checked /> <span>Lights & glow</span></label>
<label class="checkRow"><input id="settingParticles" type="checkbox" checked /> <span>Particles & ambient FX</span></label>
<label class="checkRow"><input id="settingDayNight" type="checkbox" checked /> <span>Day / night cycle</span></label>
<label class="field displayLimitField">Island display cap
<input id="displayLimit" type="number" min="25" max="500" step="25" value="250" />
</label>
</div>
<div id="rotationStats" class="syncStats"></div>
</div>
<div class="card stack">
<div class="cardTitle">Phase 5 guardrails</div>
<p class="hint">Local pre-server checks for object volume, orphaned placements, terrain compatibility, hidden objects, and report logs.</p>
@ -270,7 +272,11 @@
</div>
</div>
<script src="./js/editor-actions.js"></script>
<script src="./js/core-utils.js"></script>
<script src="./js/module-loader.js"></script>
<script src="./js/state-index.js"></script>
<script src="./js/rotation-policy.js"></script>
<script src="./js/lighting.js"></script>
<script src="./js/phase2-sync.js"></script>
<script src="./app.js"></script>
</body>

View file

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

33
js/core-utils.js Normal file
View file

@ -0,0 +1,33 @@
(function () {
'use strict';
const root = window.PixelIslandModules ||= {};
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function mod(n, m) {
return ((n % m) + m) % m;
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
function fnv1a(value) {
let hash = 0x811c9dc5;
const text = String(value);
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16).padStart(8, '0');
}
function uid() {
return Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-5);
}
root.CoreUtils = { clamp, mod, lerp, fnv1a, uid };
})();

View file

@ -2,67 +2,115 @@
'use strict';
const root = window.PixelIslandModules ||= {};
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const lerp = (a, b, t) => a + (b - a) * t;
const mod = (n, m) => ((n % m) + m) % m;
const utils = root.CoreUtils || {};
const clamp = utils.clamp || ((value, min, max) => Math.max(min, Math.min(max, value)));
const lerp = utils.lerp || ((a, b, t) => a + (b - a) * t);
const mod = utils.mod || ((n, m) => ((n % m) + m) % m);
function 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 smooth(t) {
const n = clamp(t, 0, 1);
return n * n * (3 - 2 * n);
}
function getShadowForMinute(minute) {
const light = getCelestialLightForMinute(minute);
const length = lerp(light.isNight ? 1.35 : 1.9, light.isNight ? .85 : .52, light.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, light.elevation)
};
const m = clamp(Number(minute) || 0, 0, 10);
let dirX = -0.9;
let length = 1.2;
let alpha = 0;
let scaleY = 0.26;
if (m < 0.65) {
const t = smooth(m / 0.65);
dirX = lerp(0.42, 0.18, t);
length = lerp(1.05, 0.92, t);
alpha = lerp(0.12, 0.0, t);
scaleY = lerp(0.22, 0.18, t);
} else if (m < 1.35) {
const t = smooth((m - 0.65) / 0.70);
dirX = lerp(-1.05, -0.82, t);
length = lerp(1.38, 1.12, t);
alpha = lerp(0.0, 0.27, t);
scaleY = lerp(0.22, 0.27, t);
} else if (m < 5.65) {
const t = smooth((m - 1.35) / 4.30);
const elevation = Math.sin(t * Math.PI);
dirX = lerp(-0.82, 0.98, t);
length = lerp(1.12, 1.34, t) - elevation * 0.48;
alpha = lerp(0.27, 0.20, elevation);
scaleY = 0.24 + 0.06 * (1 - elevation);
} else if (m < 6.40) {
const t = smooth((m - 5.65) / 0.75);
dirX = lerp(0.98, 1.08, t);
length = lerp(1.34, 1.56, t);
alpha = lerp(0.20, 0.0, t);
scaleY = lerp(0.28, 0.20, t);
} else if (m < 7.25) {
const t = smooth((m - 6.40) / 0.85);
dirX = lerp(0.20, -0.36, t);
length = lerp(0.92, 1.08, t);
alpha = lerp(0.0, 0.12, t);
scaleY = lerp(0.18, 0.23, t);
} else if (m < 9.20) {
const t = smooth((m - 7.25) / 1.95);
dirX = lerp(-0.36, -0.58, t);
length = lerp(1.08, 1.18, t);
alpha = 0.12;
scaleY = 0.23;
} else {
const t = smooth((m - 9.20) / 0.80);
dirX = lerp(-0.58, 0.42, t);
length = lerp(1.18, 1.05, t);
alpha = lerp(0.12, 0.0, t);
scaleY = lerp(0.23, 0.18, t);
}
return { dirX, length, skewX: dirX * (0.72 + 0.32 * length), scaleY, alpha };
}
function getPhase(dayMs, now = Date.now()) {
const t = mod(now, dayMs);
function getPhase(options) {
const { dayMs, now, dayNightEnabled, mixHex } = options;
if (dayNightEnabled === false) {
return { key: 'day', label: 'Day', progress: 0.25, sky: '#86d5ff', darkness: 0, darkOverlay: 'rgba(12, 19, 45, 0)', tint: 'rgba(255,255,255,0)', tintAlpha: 0, shadow: getShadowForMinute(3) };
}
const t = mod(now || Date.now(), dayMs);
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] }
{ at: 0.00, key: 'preDawn', label: 'Night', sky: '#17254e', darkness: 0.52, tint: [22, 28, 66, 0.10], overlay: [12, 19, 45] },
{ at: 0.65, key: 'dawn', label: 'Morning', sky: '#5969aa', darkness: 0.30, tint: [120, 100, 150, 0.10], overlay: [40, 43, 78] },
{ at: 1.35, key: 'morning', label: 'Morning', sky: '#f6b16b', darkness: 0.12, tint: [255, 195, 126, 0.13], overlay: [114, 64, 36] },
{ at: 2.15, key: 'day', label: 'Day', sky: '#8ad8ff', darkness: 0.00, tint: [255, 255, 255, 0.00], overlay: [12, 19, 45] },
{ at: 4.75, key: 'day', label: 'Day', sky: '#8ad8ff', darkness: 0.00, tint: [255, 255, 255, 0.00], overlay: [12, 19, 45] },
{ at: 5.65, key: 'evening', label: 'Evening', sky: '#ffa153', darkness: 0.07, tint: [255, 166, 88, 0.10], overlay: [130, 48, 18] },
{ at: 6.40, key: 'evening', label: 'Evening', sky: '#d55a2d', darkness: 0.20, tint: [255, 118, 62, 0.17], overlay: [118, 42, 18] },
{ at: 7.25, key: 'night', label: 'Night', sky: '#1b2a58', darkness: 0.46, tint: [32, 40, 82, 0.07], overlay: [12, 19, 45] },
{ at: 9.20, key: 'night', label: 'Night', sky: '#132247', darkness: 0.58, tint: [16, 24, 58, 0.10], overlay: [12, 19, 45] },
{ at: 10.00, key: 'preDawn', label: 'Night', sky: '#17254e', darkness: 0.52, tint: [22, 28, 66, 0.10], overlay: [12, 19, 45] }
];
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]; }
if (minute >= stops[i].at && minute < stops[i + 1].at) {
a = stops[i];
b = stops[i + 1];
break;
}
}
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';
const eased = smooth((minute - a.at) / Math.max(0.0001, b.at - a.at));
const tint = a.tint.map((value, i) => lerp(value, b.tint[i], eased));
const overlay = a.overlay.map((value, i) => lerp(value, b.overlay[i], eased));
const darkness = lerp(a.darkness, b.darkness, eased);
const dominantKey = darkness >= 0.34 ? 'night' : (a.key === 'evening' || b.key === 'evening' ? 'evening' : (a.key === 'morning' || b.key === 'morning' || a.key === 'dawn' || b.key === 'dawn' ? 'morning' : 'day'));
return {
key: label.toLowerCase(),
label,
key: dominantKey,
label: dominantKey === 'night' ? 'Night' : dominantKey === 'evening' ? 'Evening' : dominantKey === 'morning' ? 'Morning' : 'Day',
progress: t / dayMs,
darkness: lerp(a.darkness, b.darkness, eased),
sky: mixHex(a.sky, b.sky, eased),
darkness,
darkOverlay: `rgba(${Math.round(overlay[0])}, ${Math.round(overlay[1])}, ${Math.round(overlay[2])}, ${darkness.toFixed(3)})`,
tint: `rgba(${Math.round(tint[0])}, ${Math.round(tint[1])}, ${Math.round(tint[2])}, ${tint[3].toFixed(3)})`,
light: getCelestialLightForMinute(minute),
tintAlpha: tint[3],
shadow: getShadowForMinute(minute)
};
}
root.Lighting = { getPhase, getShadowForMinute, getCelestialLightForMinute };
root.Lighting = { getPhase, getShadowForMinute };
})();

22
js/module-loader.js Normal file
View file

@ -0,0 +1,22 @@
(function () {
'use strict';
const root = window.PixelIslandModules ||= {};
const pending = new Map();
function loadScript(src) {
if (pending.has(src)) return pending.get(src);
const promise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.defer = true;
script.onload = () => resolve(script);
script.onerror = () => reject(new Error(`Could not load ${src}`));
document.head.appendChild(script);
});
pending.set(src, promise);
return promise;
}
root.ModuleLoader = { loadScript };
})();

View file

@ -595,6 +595,10 @@
packAsset,
unpackAsset,
unpackAssets,
packPlacement,
unpackPlacement,
packDynamic,
unpackDynamic,
compactState,
expandState,
compactSizeReport,

View file

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

View file

@ -1,11 +0,0 @@
(function () {
'use strict';
const root = window.PixelIslandModules ||= {};
root.RenderPipeline = {
run(stages, context) {
for (const stage of stages) stage(context);
}
};
})();

76
js/rotation-policy.js Normal file
View file

@ -0,0 +1,76 @@
(function () {
'use strict';
const root = window.PixelIslandModules ||= {};
const utils = root.CoreUtils || {};
const fnv1a = utils.fnv1a || ((value) => {
let hash = 0x811c9dc5;
for (const ch of String(value)) {
hash ^= ch.charCodeAt(0);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16).padStart(8, '0');
});
const DEFAULT_CONFIG = {
defaultDisplayLimit: 250,
newArrivalSlots: 150,
revivalSlots: 100,
publishLimitFirstDay: 5,
publishLimitTrusted: 10,
upvoteDelaySlots: 20,
downvoteAdvanceSlots: 25,
upvoteRankCap: 50
};
function objectPublicAt(object) {
return Number(object?.publishedAt || object?.placedAt || object?.createdAt || Date.now());
}
function entry(kind, object, baseIndex, votes, config = DEFAULT_CONFIG) {
const rawUp = Number(votes?.up) || 0;
const up = Math.min(rawUp, config.upvoteRankCap);
const down = Number(votes?.down) || 0;
return {
kind,
object,
id: object?.id,
assetId: object?.assetId,
publicAt: objectPublicAt(object),
baseIndex,
up,
rawUp,
down,
effectiveSlot: baseIndex + up * config.upvoteDelaySlots - down * config.downvoteAdvanceSlots
};
}
function seededScore(id, salt, rotationAt = Date.now()) {
const day = Math.floor((rotationAt || Date.now()) / (24 * 60 * 60 * 1000));
return parseInt(fnv1a(`${id}|${salt}|${day}`).slice(0, 8), 16) / 0xffffffff;
}
function buckets(entries, localLimit, config = DEFAULT_CONFIG, rotationAt = Date.now()) {
const newCap = Math.min(config.newArrivalSlots, localLimit);
const revivalCap = Math.max(0, Math.min(config.revivalSlots, localLimit - newCap));
const newest = entries
.slice()
.sort((a, b) => b.effectiveSlot - a.effectiveSlot || b.publicAt - a.publicAt || String(b.id).localeCompare(String(a.id)))
.slice(0, newCap);
const newestIds = new Set(newest.map((item) => item.id));
const revival = entries
.filter((item) => !newestIds.has(item.id))
.sort((a, b) => seededScore(b.id, 'revival', rotationAt) - seededScore(a.id, 'revival', rotationAt) || b.up - a.up || String(b.id).localeCompare(String(a.id)))
.slice(0, revivalCap);
return { newest, revival, entries, visibleIds: new Set([...newest, ...revival].map((item) => item.id)) };
}
function publishLimit(account, now = Date.now(), config = DEFAULT_CONFIG) {
if (!account?.createdAt) return 0;
return now - Number(account.createdAt) < 24 * 60 * 60 * 1000
? config.publishLimitFirstDay
: config.publishLimitTrusted;
}
root.RotationPolicy = { DEFAULT_CONFIG, objectPublicAt, entry, buckets, seededScore, publishLimit };
})();

103
js/state-index.js Normal file
View file

@ -0,0 +1,103 @@
(function () {
'use strict';
const root = window.PixelIslandModules ||= {};
function tileKey(x, y) {
return `${Math.round(x)},${Math.round(y)}`;
}
function pushBucket(map, key, item) {
const bucket = map.get(key);
if (bucket) bucket.push(item);
else map.set(key, [item]);
}
function build(state) {
const index = {
assetById: new Map(),
staticById: new Map(),
dynamicById: new Map(),
objectsById: new Map(),
placedByTile: new Map(),
dynamicByHomeTile: new Map(),
objectIdsByAssetId: new Map()
};
for (const asset of state.assets || []) {
if (asset?.id) index.assetById.set(asset.id, asset);
}
for (const placed of state.placed || []) {
if (!placed?.id) continue;
index.staticById.set(placed.id, placed);
index.objectsById.set(placed.id, { kind: 'static', object: placed });
pushBucket(index.placedByTile, tileKey(placed.x, placed.y), placed);
pushBucket(index.objectIdsByAssetId, placed.assetId, placed.id);
}
for (const summon of state.dynamicSummons || []) {
if (!summon?.id) continue;
index.dynamicById.set(summon.id, summon);
index.objectsById.set(summon.id, { kind: 'dynamic', object: summon });
pushBucket(index.dynamicByHomeTile, tileKey(summon.homeX, summon.homeY), summon);
pushBucket(index.objectIdsByAssetId, summon.assetId, summon.id);
}
return index;
}
function reduce(state, event, handlers = {}) {
if (!state || !event) return state;
switch (event.type) {
case 'asset.upsert': {
const asset = handlers.unpackAsset ? handlers.unpackAsset(event.asset) : event.asset;
if (!asset?.id) return state;
const list = state.assets ||= [];
const index = list.findIndex((item) => item.id === asset.id);
if (index >= 0) list[index] = asset;
else list.unshift(asset);
break;
}
case 'asset.delete': {
const assetId = event.assetId;
if (!assetId) return state;
const removed = new Set();
for (const item of state.placed || []) if (item.assetId === assetId) removed.add(item.id);
for (const item of state.dynamicSummons || []) if (item.assetId === assetId) removed.add(item.id);
state.assets = (state.assets || []).filter((asset) => asset.id !== assetId);
state.placed = (state.placed || []).filter((item) => item.assetId !== assetId);
state.dynamicSummons = (state.dynamicSummons || []).filter((item) => item.assetId !== assetId);
delete state.assetVotes?.[assetId];
delete state.hiddenAssets?.[assetId];
for (const id of removed) {
delete state.objectVotes?.[id];
delete state.hiddenObjects?.[id];
}
state.moderationReports = (state.moderationReports || []).filter((report) => !removed.has(report.objectId));
break;
}
case 'object.upsert': {
const object = handlers.unpackObject ? handlers.unpackObject(event.kind, event.object) : event.object;
if (!object?.id) return state;
const key = event.kind === 'dynamic' ? 'dynamicSummons' : 'placed';
const list = state[key] ||= [];
const index = list.findIndex((item) => item.id === object.id);
if (index >= 0) list[index] = object;
else list.push(object);
break;
}
case 'object.delete': {
const key = event.kind === 'dynamic' ? 'dynamicSummons' : 'placed';
state[key] = (state[key] || []).filter((item) => item.id !== event.objectId);
delete state.objectVotes?.[event.objectId];
delete state.hiddenObjects?.[event.objectId];
state.moderationReports = (state.moderationReports || []).filter((report) => report.objectId !== event.objectId);
break;
}
}
return state;
}
root.StateIndex = { build, reduce, tileKey };
})();

View file

@ -1,24 +0,0 @@
(function () {
'use strict';
const root = window.PixelIslandModules ||= {};
root.Vec2 = {
add(a, b) {
return { x: a.x + b.x, y: a.y + b.y };
},
sub(a, b) {
return { x: a.x - b.x, y: a.y - b.y };
},
scale(v, amount) {
return { x: v.x * amount, y: v.y * amount };
},
length(v) {
return Math.hypot(v.x, v.y);
},
normalize(v) {
const length = Math.hypot(v.x, v.y) || 1;
return { x: v.x / length, y: v.y / length };
}
};
})();

View file

@ -0,0 +1,12 @@
{
"display_limit": 250,
"newest_slots": 150,
"revival_slots": 100,
"revival_sample_size": 50,
"revival_pick_count": 20,
"upvote_delay_slots": 20,
"downvote_advance_slots": 25,
"upvote_rank_cap": 50,
"extreme_downvotes": 10,
"extreme_margin": 8
}

View file

@ -65,6 +65,33 @@ class RotationConfig:
extreme_downvotes: int = EXTREME_DOWNVOTES
extreme_margin: int = EXTREME_MARGIN
def __post_init__(self) -> None:
self.display_limit = max(0, int(self.display_limit))
self.newest_slots = max(0, int(self.newest_slots))
self.revival_slots = max(0, int(self.revival_slots))
if self.newest_slots + self.revival_slots > self.display_limit:
self.revival_slots = max(0, self.display_limit - self.newest_slots)
self.newest_slots = min(self.newest_slots, self.display_limit)
@classmethod
def from_mapping(cls, values: MutableMapping[str, Any] | None) -> "RotationConfig":
data = values or {}
allowed = set(cls.__dataclass_fields__)
clean = {key: int(value) for key, value in data.items() if key in allowed and value is not None}
return cls(**clean)
def load_config(path: Optional[Path], overrides: MutableMapping[str, Any] | None = None) -> RotationConfig:
data: Dict[str, Any] = {}
if path:
with path.open("r", encoding="utf-8") as f:
loaded = json.load(f)
if not isinstance(loaded, MutableMapping):
raise ValueError("Rotation config JSON root must be an object")
data.update(loaded)
data.update({k: v for k, v in (overrides or {}).items() if v is not None})
return RotationConfig.from_mapping(data)
def now_ms() -> int:
return int(time.time() * 1000)
@ -287,9 +314,10 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--write", action="store_true")
parser.add_argument("--out", type=Path)
parser.add_argument("--seed", type=int)
parser.add_argument("--display-limit", type=int, default=DISPLAY_LIMIT)
parser.add_argument("--newest-slots", type=int, default=NEWEST_SLOTS)
parser.add_argument("--revival-slots", type=int, default=REVIVAL_SLOTS)
parser.add_argument("--config", type=Path, help="Optional rotation policy JSON file.")
parser.add_argument("--display-limit", type=int)
parser.add_argument("--newest-slots", type=int)
parser.add_argument("--revival-slots", type=int)
parser.add_argument("--restore", nargs="*", default=None, help="Admin restore object IDs from permanent/violation hidden to rotation hidden.")
parser.add_argument("--hide-violation", nargs="*", default=None, help="Admin hide object IDs as violation_hidden.")
return parser.parse_args()
@ -298,7 +326,7 @@ def parse_args() -> argparse.Namespace:
def main() -> int:
args = parse_args()
state = load_json(args.world_json)
config = RotationConfig(display_limit=args.display_limit, newest_slots=args.newest_slots, revival_slots=args.revival_slots)
config = load_config(args.config, {"display_limit": args.display_limit, "newest_slots": args.newest_slots, "revival_slots": args.revival_slots})
summary: Dict[str, Any] = {}
if args.restore:
summary["restored"] = restore_permanent(state, args.restore)

View file

@ -0,0 +1,64 @@
import tempfile
import unittest
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from rotation_worker import (
ACTIVE,
HIDDEN_ROTATION,
VIOLATION_HIDDEN,
RotationConfig,
account_publish_limit,
apply_exhibition_cap,
hide_violation,
load_config,
restore_permanent,
)
class RotationWorkerTest(unittest.TestCase):
def test_account_publish_limit_changes_after_first_day(self):
now = 2_000_000_000
self.assertEqual(account_publish_limit({"id": "a", "createdAt": now}, now), 5)
self.assertEqual(account_publish_limit({"id": "a", "createdAt": now - 25 * 60 * 60 * 1000}, now), 10)
def test_exhibition_cap_hides_old_excess_objects(self):
state = {
"assets": [],
"placed": [
{"id": "old", "assetId": "a", "x": 1, "y": 1, "publishedAt": 10},
{"id": "new", "assetId": "b", "x": 2, "y": 2, "publishedAt": 20},
],
"dynamicSummons": [],
"objectVotes": {},
}
summary = apply_exhibition_cap(state, RotationConfig(display_limit=1, newest_slots=1, revival_slots=0), seed=1, now=30)
self.assertEqual(summary["active"], 1)
self.assertEqual(state["placed"][1]["status"], ACTIVE)
self.assertEqual(state["placed"][0]["status"], HIDDEN_ROTATION)
def test_display_limit_caps_slot_total(self):
config = RotationConfig(display_limit=3, newest_slots=5, revival_slots=5)
self.assertEqual(config.newest_slots + config.revival_slots, 3)
def test_violation_hide_and_restore(self):
state = {"placed": [{"id": "obj", "assetId": "a"}], "dynamicSummons": []}
self.assertEqual(hide_violation(state, ["obj"], now=1), ["obj"])
self.assertEqual(state["placed"][0]["status"], VIOLATION_HIDDEN)
self.assertEqual(restore_permanent(state, ["obj"], now=2), ["obj"])
self.assertEqual(state["placed"][0]["status"], HIDDEN_ROTATION)
def test_load_config_from_json_with_override(self):
with tempfile.TemporaryDirectory() as temp:
path = Path(temp) / "policy.json"
path.write_text('{"display_limit": 40, "newest_slots": 30}', encoding="utf-8")
config = load_config(path, {"revival_slots": 7})
self.assertEqual(config.display_limit, 40)
self.assertEqual(config.newest_slots, 30)
self.assertEqual(config.revival_slots, 7)
if __name__ == "__main__":
unittest.main()

View file

@ -1459,3 +1459,37 @@ body, button, input, select, textarea { font-size: 15px; }
@media (max-width: 760px) {
.analogClock { top: 82px; right: 10px; transform: scale(.86); transform-origin: top right; }
}
/* v16 layout refinements */
.inlineField {
display: grid;
grid-template-columns: 86px minmax(0, 1fr);
align-items: center;
gap: 8px;
}
.inlineField > input,
.inlineField > select { margin-top: 0 !important; }
.quickSetupBar.twoCols { gap: 8px 12px; }
.compactNameField.inlineField { margin-top: 6px; }
.tabs .tab[data-tab="settings"] { display: inline-flex; }
.assetCard { cursor: pointer; align-items: center; }
.assetCard:not(.expanded) .assetMeta { align-self: center; }
.assetCard:not(.expanded) { padding: 7px 8px; }
.assetCard:not(.expanded) .assetMeta strong { font-size: 13px; }
.assetCard:not(.expanded) .assetMeta span { font-size: 11px; margin-top: 2px; }
.assetCard.expanded { align-items: start; }
.selectionBubble {
position: fixed !important;
z-index: 50 !important;
}
.analogClock {
top: 218px;
right: 30px;
}
@media (max-width: 760px) {
.analogClock { top: 176px; right: 12px; transform: scale(.82); transform-origin: top right; }
}

View file

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