split
This commit is contained in:
parent
a05c031074
commit
94571943cf
23 changed files with 817 additions and 5563 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
*.bak
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
12
README.md
12
README.md
|
|
@ -6,6 +6,14 @@ Browser-only prototype for a shared pixel-art island.
|
|||
|
||||
Open `index.html` in a modern browser. No build step or third-party dependency is required.
|
||||
|
||||
## Refactor layout
|
||||
|
||||
- `app.js` remains the browser bootstrap and main UI coordinator.
|
||||
- `js/core-utils.js`, `js/state-index.js`, `js/rotation-policy.js`, and `js/lighting.js` hold reusable logic shared by the app.
|
||||
- `js/editor-actions.js` is lazy-loaded when Pixel Studio opens; the app keeps inline fallbacks for first-use safety.
|
||||
- Server rotation defaults can be supplied with `server/rotation_policy.json` and `--config`.
|
||||
- Server policy tests live in `server/test_rotation_worker.py`.
|
||||
|
||||
## Current prototype scope
|
||||
|
||||
- Draw pixel assets in the in-browser editor.
|
||||
|
|
@ -269,3 +277,7 @@ Use this to compare minified JSON, gzip, zlib, and Brotli when available. Produc
|
|||
- Replaced the numeric clock with a fixed analog day-night clock using a sky-color conic gradient and no numbers.
|
||||
- Kept selected-object shadows mirrored from the ground line while the sprite jumps.
|
||||
- Smoothed sky and overlay interpolation across dawn, day, evening, and night.
|
||||
|
||||
## v16b hotfix
|
||||
- Fixed the dark/blank world regression caused by using `source-in` compositing directly on the main world canvas for ship water reflection.
|
||||
- Ship reflections are now precomposited on an offscreen canvas before drawing, so terrain and sprites are not erased or darkened.
|
||||
|
|
|
|||
|
|
@ -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
428
app.js
|
|
@ -20,16 +20,22 @@
|
|||
const VIEW_CULL_MARGIN = 192;
|
||||
const DYNAMIC_LOGIC_STEP_MS = 1000 / 15;
|
||||
const MAX_DYNAMIC_STEPS_PER_FRAME = 3;
|
||||
const MAX_SPRITE_CACHE_ENTRIES = 260;
|
||||
|
||||
const COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const PALETTE = buildPalette();
|
||||
const PALETTE_BY_CODE = Object.fromEntries(PALETTE.map((p) => [p.code, p.color]));
|
||||
const MODULES = window.PixelIslandModules || {};
|
||||
const EditorActions = MODULES.EditorActions || null;
|
||||
const Phase2Sync = MODULES.Phase2Sync || null;
|
||||
const StateIndex = MODULES.StateIndex || null;
|
||||
const RotationPolicy = MODULES.RotationPolicy || null;
|
||||
const Lighting = MODULES.Lighting || null;
|
||||
const ModuleLoader = MODULES.ModuleLoader || null;
|
||||
const EDITOR_HISTORY_LIMIT = 80;
|
||||
const PHASE5_GUARDRAILS = { maxAssets: 220, maxWorldObjects: 1000, maxReports: 200, defaultDisplayLimit: 250, newArrivalSlots: 150, revivalSlots: 100, publishLimitFirstDay: 5, publishLimitTrusted: 10, upvoteDelaySlots: 20, downvoteAdvanceSlots: 25, upvoteRankCap: 50, maxParticles: 200, particleMinZoom: 0.72 };
|
||||
|
||||
const editorActions = () => MODULES.EditorActions || null;
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
||||
const uid = () => Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-5);
|
||||
|
|
@ -143,6 +149,7 @@
|
|||
|
||||
let world = makeWorld();
|
||||
let terrainCache = buildTerrainCache(world);
|
||||
let terrainFrontCache = buildTerrainFrontCache(world);
|
||||
let state = loadState();
|
||||
let selectedAssetId = state.assets[0]?.id ?? null;
|
||||
let mode = 'inspect';
|
||||
|
|
@ -154,6 +161,7 @@
|
|||
let hoverTile = null;
|
||||
let dynamicRuntime = [];
|
||||
let spriteCache = new Map();
|
||||
let lastRenderedSpriteInfo = new Map();
|
||||
let lastRuntimeUpdate = performance.now();
|
||||
let lastClockSecond = -1;
|
||||
let toastTimer = null;
|
||||
|
|
@ -186,6 +194,7 @@
|
|||
let coastalFoamTextures = null;
|
||||
let mousePaint = { active: false, panning: false, lastX: 0, lastY: 0, button: 0 };
|
||||
let shadowCanvasCache = new WeakMap();
|
||||
let shipReflectionCanvasCache = new WeakMap();
|
||||
let animationFrameId = 0;
|
||||
let dynamicLogicRemainder = 0;
|
||||
let worldIndex = makeEmptyWorldIndex();
|
||||
|
|
@ -210,6 +219,7 @@
|
|||
function bootstrap() {
|
||||
resizeCanvas();
|
||||
resetView(false);
|
||||
rebuildWorldIndex();
|
||||
hydrateRuntime();
|
||||
coastalFoamTextures = buildCoastalFoamTextures();
|
||||
wireUI();
|
||||
|
|
@ -221,7 +231,6 @@
|
|||
clearEditorHistory();
|
||||
renderLibrary();
|
||||
updateSelectedLabel();
|
||||
rebuildWorldIndex();
|
||||
updateSyncStats();
|
||||
updateGuardrailStats();
|
||||
hydrateVisualSettingsUI();
|
||||
|
|
@ -478,12 +487,19 @@
|
|||
function setDrawerOpen(open) {
|
||||
els.drawer.classList.toggle('open', open);
|
||||
els.openEditor.hidden = open;
|
||||
if (open) ensureEditorHelpers();
|
||||
if (!open) {
|
||||
selectedObject = null;
|
||||
updateSelectionBubble(performance.now());
|
||||
}
|
||||
}
|
||||
|
||||
function ensureEditorHelpers() {
|
||||
if (editorActions() || !ModuleLoader?.loadScript) return;
|
||||
ModuleLoader.loadScript('./js/editor-actions.js')
|
||||
.catch((error) => console.warn('Editor helper lazy load failed; using inline fallbacks.', error));
|
||||
}
|
||||
|
||||
function onDocumentContextMenu(event) {
|
||||
if (!els.drawer?.classList.contains('open')) return;
|
||||
if (els.drawer.contains(event.target) || els.openEditor?.contains(event.target) || els.openCreate?.contains(event.target) || els.openCollection?.contains(event.target) || els.openMenu?.contains(event.target)) return;
|
||||
|
|
@ -754,7 +770,7 @@
|
|||
}
|
||||
|
||||
function makeEmptyWorldIndex() {
|
||||
return { placedByTile: new Map(), dynamicByHomeTile: new Map() };
|
||||
return StateIndex?.build ? StateIndex.build({ assets: [], placed: [], dynamicSummons: [] }) : { assetById: new Map(), staticById: new Map(), dynamicById: new Map(), objectsById: new Map(), objectIdsByAssetId: new Map(), placedByTile: new Map(), dynamicByHomeTile: new Map() };
|
||||
}
|
||||
|
||||
function pushIndexBucket(map, key, item) {
|
||||
|
|
@ -764,12 +780,25 @@
|
|||
}
|
||||
|
||||
function rebuildWorldIndex() {
|
||||
if (StateIndex?.build) {
|
||||
worldIndex = StateIndex.build(state);
|
||||
return;
|
||||
}
|
||||
const next = makeEmptyWorldIndex();
|
||||
for (const asset of state.assets || []) {
|
||||
if (asset?.id) next.assetById.set(asset.id, asset);
|
||||
}
|
||||
for (const placed of state.placed || []) {
|
||||
next.staticById.set(placed.id, placed);
|
||||
next.objectsById.set(placed.id, { kind: 'static', object: placed });
|
||||
pushIndexBucket(next.placedByTile, tileKey(placed.x, placed.y), placed);
|
||||
pushIndexBucket(next.objectIdsByAssetId, placed.assetId, placed.id);
|
||||
}
|
||||
for (const summon of state.dynamicSummons || []) {
|
||||
next.dynamicById.set(summon.id, summon);
|
||||
next.objectsById.set(summon.id, { kind: 'dynamic', object: summon });
|
||||
pushIndexBucket(next.dynamicByHomeTile, tileKey(Math.round(summon.homeX), Math.round(summon.homeY)), summon);
|
||||
pushIndexBucket(next.objectIdsByAssetId, summon.assetId, summon.id);
|
||||
}
|
||||
worldIndex = next;
|
||||
}
|
||||
|
|
@ -789,12 +818,12 @@
|
|||
}
|
||||
|
||||
function getObjectRecord(kind, objectId) {
|
||||
if (kind === 'static') return state.placed.find((p) => p.id === objectId) || null;
|
||||
return state.dynamicSummons.find((p) => p.id === objectId) || null;
|
||||
if (kind === 'static') return worldIndex.staticById?.get(objectId) || state.placed.find((p) => p.id === objectId) || null;
|
||||
return worldIndex.dynamicById?.get(objectId) || state.dynamicSummons.find((p) => p.id === objectId) || null;
|
||||
}
|
||||
|
||||
function getObjectPublicAt(kind, object) {
|
||||
return Number(object?.publishedAt || object?.placedAt || object?.createdAt || Date.now());
|
||||
return RotationPolicy?.objectPublicAt ? RotationPolicy.objectPublicAt(object) : Number(object?.publishedAt || object?.placedAt || object?.createdAt || Date.now());
|
||||
}
|
||||
|
||||
function isPermanentlyHiddenObject(object) {
|
||||
|
|
@ -804,8 +833,12 @@
|
|||
function isModeratedAssetHidden(asset) {
|
||||
if (!asset) return false;
|
||||
if (asset.status === 'permanent_hidden' || asset.status === 'violation_hidden') return true;
|
||||
const allObjects = [...(state.placed || []), ...(state.dynamicSummons || [])];
|
||||
return allObjects.some((object) => object.assetId === asset.id && isPermanentlyHiddenObject(object) && object.hiddenReason === 'moderation_violation');
|
||||
const objectIds = worldIndex.objectIdsByAssetId?.get(asset.id) || [];
|
||||
for (const objectId of objectIds) {
|
||||
const entry = worldIndex.objectsById?.get(objectId);
|
||||
if (entry?.object && isPermanentlyHiddenObject(entry.object) && entry.object.hiddenReason === 'moderation_violation') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isServerSuppressedObject(object) {
|
||||
|
|
@ -813,25 +846,12 @@
|
|||
}
|
||||
|
||||
function getObjectRotationEntry(kind, object, baseIndex = 0) {
|
||||
const objectId = object?.id;
|
||||
const votes = getObjectVoteCounts(objectId);
|
||||
const votes = getObjectVoteCounts(object?.id);
|
||||
if (RotationPolicy?.entry) return RotationPolicy.entry(kind, object, baseIndex, votes, PHASE5_GUARDRAILS);
|
||||
const rawUp = Number(votes.up) || 0;
|
||||
const up = Math.min(rawUp, PHASE5_GUARDRAILS.upvoteRankCap);
|
||||
const down = Number(votes.down) || 0;
|
||||
const delay = up * PHASE5_GUARDRAILS.upvoteDelaySlots;
|
||||
const advance = down * PHASE5_GUARDRAILS.downvoteAdvanceSlots;
|
||||
return {
|
||||
kind,
|
||||
object,
|
||||
id: objectId,
|
||||
assetId: object?.assetId,
|
||||
publicAt: getObjectPublicAt(kind, object),
|
||||
baseIndex,
|
||||
up,
|
||||
rawUp,
|
||||
down,
|
||||
effectiveSlot: baseIndex + delay - advance
|
||||
};
|
||||
return { kind, object, id: object?.id, assetId: object?.assetId, publicAt: getObjectPublicAt(kind, object), baseIndex, up, rawUp, down, effectiveSlot: baseIndex + up * PHASE5_GUARDRAILS.upvoteDelaySlots - down * PHASE5_GUARDRAILS.downvoteAdvanceSlots };
|
||||
}
|
||||
|
||||
function getRotationEntries(includeLocalHidden = false) {
|
||||
|
|
@ -853,6 +873,7 @@
|
|||
}
|
||||
|
||||
function seededScore(id, salt = '') {
|
||||
if (RotationPolicy?.seededScore) return RotationPolicy.seededScore(id, salt, state.lastRotationAt || Date.now());
|
||||
const hash = fnv1a(`${id}|${salt}|${Math.floor((state.lastRotationAt || Date.now()) / (24 * 60 * 60 * 1000))}`);
|
||||
return parseInt(hash.slice(0, 8), 16) / 0xffffffff;
|
||||
}
|
||||
|
|
@ -860,6 +881,7 @@
|
|||
function getIslandDisplayBuckets() {
|
||||
const entries = getRotationEntries(false);
|
||||
const localLimit = getLocalDisplayLimit();
|
||||
if (RotationPolicy?.buckets) return RotationPolicy.buckets(entries, localLimit, PHASE5_GUARDRAILS, state.lastRotationAt || Date.now());
|
||||
const newCap = Math.min(PHASE5_GUARDRAILS.newArrivalSlots, localLimit);
|
||||
const revivalCap = Math.max(0, Math.min(PHASE5_GUARDRAILS.revivalSlots, localLimit - newCap));
|
||||
const newest = entries
|
||||
|
|
@ -904,6 +926,7 @@
|
|||
|
||||
function currentPublishLimit(now = Date.now()) {
|
||||
if (!state.account?.createdAt) return 0;
|
||||
if (RotationPolicy?.publishLimit) return RotationPolicy.publishLimit(state.account, now, PHASE5_GUARDRAILS);
|
||||
return getAccountAgeMs(now) < 24 * 60 * 60 * 1000
|
||||
? PHASE5_GUARDRAILS.publishLimitFirstDay
|
||||
: PHASE5_GUARDRAILS.publishLimitTrusted;
|
||||
|
|
@ -1473,8 +1496,9 @@
|
|||
if (paintTool === 'fill') {
|
||||
const displayPixels = getDisplayEditorPixels();
|
||||
const fillColor = shiftKey ? null : selectedColorCode;
|
||||
const nextDisplay = EditorActions?.floodFill
|
||||
? EditorActions.floodFill(displayPixels, editorSize, x, y, fillColor)
|
||||
const actions = editorActions();
|
||||
const nextDisplay = actions?.floodFill
|
||||
? actions.floodFill(displayPixels, editorSize, x, y, fillColor)
|
||||
: fallbackFloodFill(displayPixels, editorSize, x, y, fillColor);
|
||||
if (nextDisplay.changed) {
|
||||
editorPixels = nextDisplay.pixels;
|
||||
|
|
@ -2560,6 +2584,7 @@
|
|||
editParentId = null;
|
||||
editOriginalId = null;
|
||||
editingAssetId = null;
|
||||
rebuildWorldIndex();
|
||||
saveState();
|
||||
spriteCache.clear();
|
||||
hydrateRuntime();
|
||||
|
|
@ -2818,7 +2843,8 @@
|
|||
|
||||
function makeAssetCard(asset) {
|
||||
const card = document.createElement('article');
|
||||
card.className = `assetCard${asset.id === selectedAssetId ? ' selected' : ''}`;
|
||||
const expanded = asset.id === selectedAssetId;
|
||||
card.className = `assetCard${expanded ? ' selected expanded' : ''}`;
|
||||
|
||||
const preview = document.createElement('canvas');
|
||||
preview.className = 'assetPreview';
|
||||
|
|
@ -2828,53 +2854,60 @@
|
|||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'assetMeta';
|
||||
const lineage = asset.parentAssetId ? ' / derivative' : '';
|
||||
meta.innerHTML = `<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
3774
app.js.bak
File diff suppressed because it is too large
Load diff
44
index.html
44
index.html
|
|
@ -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>
|
||||
|
|
|
|||
203
index.html.bak
203
index.html.bak
|
|
@ -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
33
js/core-utils.js
Normal 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 };
|
||||
})();
|
||||
136
js/lighting.js
136
js/lighting.js
|
|
@ -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
22
js/module-loader.js
Normal 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 };
|
||||
})();
|
||||
|
|
@ -595,6 +595,10 @@
|
|||
packAsset,
|
||||
unpackAsset,
|
||||
unpackAssets,
|
||||
packPlacement,
|
||||
unpackPlacement,
|
||||
packDynamic,
|
||||
unpackDynamic,
|
||||
compactState,
|
||||
expandState,
|
||||
compactSizeReport,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
})();
|
||||
|
|
@ -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
76
js/rotation-policy.js
Normal 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
103
js/state-index.js
Normal 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 };
|
||||
})();
|
||||
24
js/vector.js
24
js/vector.js
|
|
@ -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 };
|
||||
}
|
||||
};
|
||||
})();
|
||||
Binary file not shown.
Binary file not shown.
12
server/rotation_policy.json
Normal file
12
server/rotation_policy.json
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
64
server/test_rotation_worker.py
Normal file
64
server/test_rotation_worker.py
Normal 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()
|
||||
34
styles.css
34
styles.css
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
901
styles.css.bak
901
styles.css.bak
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue