From 2501a4c975b6ade5522a6a516429c56c03efeb90 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Tue, 2 Jun 2026 00:05:03 +0900 Subject: [PATCH] update --- README.md | 47 +++ app.js | 1010 ++++++++++++++++++++++++++++++++++++++------- index.html | 46 ++- js/phase2-sync.js | 6 + styles.css | 69 ++++ 5 files changed, 1022 insertions(+), 156 deletions(-) diff --git a/README.md b/README.md index cd5e157..10f2337 100644 --- a/README.md +++ b/README.md @@ -67,3 +67,50 @@ Open `index.html` in a modern browser. No build step or third-party dependency i - Night rendering is darker. - Object picking now uses sprite-sized, per-opaque-pixel hit testing, so transparent pixels are not clickable. - The CSS font stack now tries `3x5 MT Pixel Font` first. The font file itself is not bundled. + +## Phase 5 changes + +- Editor schema is now 7. +- Fixed the Advanced Draw depth controls so `Depth / High / Low / Clear` wrap inside the drawer instead of overflowing to the right. +- Added Phase 5 guardrails in the Data tab: + - local asset/object volume counters, + - duplicate-content count, + - orphan placement checks, + - terrain compatibility checks, + - hidden/report counters. +- Added `Validate world` export for pre-server integrity checks. +- Added local object reporting from the selection bubble. Reported objects are hidden locally and stored in a local moderation report log. +- Added `Export moderation report` for review/debug data. +- Added `Clear reports` to clear local report logs without un-hiding already hidden objects. +- Added soft local guardrail limits: 160 assets and 220 world objects. Existing objects can still be moved; new saves/placements are blocked when the local cap is reached. + +## Phase 5 follow-up changes + +- Static sprites now anchor closer to the center of their tile instead of the front corner. +- Object selection is pixel-accurate: transparent sprite cells no longer select the object, and tile inspection does not select hidden/transparent areas. +- Right-clicking the map clears selection silently. +- Hidden panel now lists both hidden library assets and hidden map objects. Objects hidden by Report appear there and can be restored with Show again. +- Report now opens a reason dialog with Cancel and Report + Hide actions. +- Library cards include Place/Move, which makes dynamic human assets easier to reposition. +- Default bundled pixel art was replaced with a new set: Hill Cottage, Pine Cluster, Shell Rock, Wave Skiff, Fisher Kid, Moss Cat, Koi Fish, and Cloud Gull. +- Added a static Ship role/subtype. Ships can be placed on water, float subtly, and periodically emit ring ripples. +- Local storage key was bumped to load the new default set in this prototype build. + +## Phase 5 polish + +- Night lights now draw after the darkness overlay and use fixed bright cores/halos, so lamp pixels visually ignore the night darkening pass. +- The sky color now transitions quickly around dawn and dusk. Day and night remain mostly stable instead of drifting continuously. + + +## Phase 5 Effects +- Nearby objects receive a faint surface glow from adjacent light sources. +- Light cores are less dazzling, while night ambient darkness is lifted slightly. +- Land-moving dynamic sprites emit small terrain-colored step particles. + + +## Visual FX follow-up +- Step particles now vary in hue / saturation / lightness around the terrain color. +- Nature assets shed tiny drifting leaf/petal dots. +- Coast foam uses two slow-rotating porous bluish-white layers. +- Depth now affects phase-aware sprite shading for sun/moon exposure. +- Added toggles for lights, particles, and the day-night cycle. diff --git a/app.js b/app.js index 433519f..35a64b6 100644 --- a/app.js +++ b/app.js @@ -3,8 +3,8 @@ console.info('Pixel Island Summoner loaded'); - const STORAGE_KEY = 'pixel-island-summoner:reset'; - const SAVE_SCHEMA = 6; + const STORAGE_KEY = 'pixel-island-summoner:phase5c'; + const SAVE_SCHEMA = 9; const WORLD_W = 144; const WORLD_H = 112; const TILE_W = 32; @@ -28,6 +28,7 @@ const EditorActions = MODULES.EditorActions || null; const Phase2Sync = MODULES.Phase2Sync || null; const EDITOR_HISTORY_LIMIT = 80; + const PHASE5_GUARDRAILS = { maxAssets: 160, maxWorldObjects: 220, maxReports: 200 }; const $ = (id) => document.getElementById(id); const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); @@ -35,6 +36,30 @@ const mod = (n, m) => ((n % m) + m) % m; const lerp = (a, b, t) => a + (b - a) * t; + function defaultVisualSettings() { + return { enableLights: true, enableParticles: true, enableDayNight: true }; + } + + function visualSettings() { + state.settings = { ...defaultVisualSettings(), ...(state.settings || {}) }; + return state.settings; + } + + function hydrateVisualSettingsUI() { + const settings = visualSettings(); + if (els.settingLights) els.settingLights.checked = settings.enableLights !== false; + if (els.settingParticles) els.settingParticles.checked = settings.enableParticles !== false; + if (els.settingDayNight) els.settingDayNight.checked = settings.enableDayNight !== false; + } + + function clearVisualEffectState() { + spawnEffects = []; + bubbleParticles = []; + confettiParticles = []; + landStepParticles = []; + natureDriftParticles = []; + } + function toggleHidden(el, hidden) { if (el) el.hidden = hidden; @@ -54,7 +79,7 @@ function subtypeToRole(asset) { const subtype = asset?.subtype; - if (['human', 'animal', 'nature', 'building', 'other'].includes(subtype)) return subtype; + if (['human', 'animal', 'nature', 'building', 'ship', 'other'].includes(subtype)) return subtype; if (subtype === 'water') return 'other'; if (asset?.category === 'dynamic') return 'animal'; return 'other'; @@ -73,7 +98,7 @@ selectedAssetName: $('selectedAssetName'), tileInfo: $('tileInfo'), toast: $('toast'), - selectionBubble: $('selectionBubble'), bubbleName: $('bubbleName'), bubbleAuthor: $('bubbleAuthor'), bubbleRemixFrom: $('bubbleRemixFrom'), bubbleRemixCount: $('bubbleRemixCount'), voteScore: $('voteScore'), voteUp: $('voteUp'), voteDown: $('voteDown'), bubbleRemix: $('bubbleRemix'), bubbleHide: $('bubbleHide'), + selectionBubble: $('selectionBubble'), bubbleName: $('bubbleName'), bubbleAuthor: $('bubbleAuthor'), bubbleRemixFrom: $('bubbleRemixFrom'), bubbleRemixCount: $('bubbleRemixCount'), voteScore: $('voteScore'), voteUp: $('voteUp'), voteDown: $('voteDown'), bubbleRemix: $('bubbleRemix'), bubbleReport: $('bubbleReport'), bubbleHide: $('bubbleHide'), modeInspect: $('modeInspect'), modePlace: $('modePlace'), modeErase: $('modeErase'), drawerInspect: $('drawerInspect'), drawerPlace: $('drawerPlace'), drawerErase: $('drawerErase'), @@ -95,7 +120,9 @@ settingsSummary: $('settingsSummary'), saveAsset: $('saveAsset'), saveAndPlace: $('saveAndPlace'), newAsset: $('newAsset'), lineageNote: $('lineageNote'), assetList: $('assetList'), showHiddenAssets: $('showHiddenAssets'), hiddenAssetPanel: $('hiddenAssetPanel'), hiddenAssetList: $('hiddenAssetList'), exportData: $('exportData'), importData: $('importData'), resetAll: $('resetAll'), dataBox: $('dataBox'), - exportCompact: $('exportCompact'), exportSnapshot: $('exportSnapshot'), exportAssetBundle: $('exportAssetBundle'), syncStats: $('syncStats') + exportCompact: $('exportCompact'), exportSnapshot: $('exportSnapshot'), exportAssetBundle: $('exportAssetBundle'), syncStats: $('syncStats'), guardrailStats: $('guardrailStats'), validateWorld: $('validateWorld'), exportModerationReport: $('exportModerationReport'), clearReports: $('clearReports'), + settingLights: $('settingLights'), settingParticles: $('settingParticles'), settingDayNight: $('settingDayNight'), + reportDialog: $('reportDialog'), reportReason: $('reportReason'), reportCancel: $('reportCancel'), reportSubmit: $('reportSubmit'), reportObjectName: $('reportObjectName') }; const ctx = els.canvas.getContext('2d', { alpha: false }); @@ -145,12 +172,16 @@ let spawnEffects = []; let bubbleParticles = []; let confettiParticles = []; + let landStepParticles = []; + let natureDriftParticles = []; + let coastalFoamTextures = null; let mousePaint = { active: false, panning: false, lastX: 0, lastY: 0, button: 0 }; let shadowCanvasCache = new WeakMap(); let animationFrameId = 0; let dynamicLogicRemainder = 0; let worldIndex = makeEmptyWorldIndex(); let selectedObject = null; + let pendingReport = null; let libraryFilter = 'all'; let renderPhase = null; let editorView = { zoom: 1, x: 0, y: 0 }; @@ -170,6 +201,7 @@ resizeCanvas(); resetView(false); hydrateRuntime(); + coastalFoamTextures = buildCoastalFoamTextures(); wireUI(); renderPalette(); hydrateAuthorUI(); @@ -180,6 +212,8 @@ updateSelectedLabel(); rebuildWorldIndex(); updateSyncStats(); + updateGuardrailStats(); + hydrateVisualSettingsUI(); scheduleFrame(); } @@ -202,6 +236,25 @@ renderLibrary(); }); + els.settingLights?.addEventListener('change', () => { + visualSettings().enableLights = !!els.settingLights.checked; + saveState(); + render(); + }); + els.settingParticles?.addEventListener('change', () => { + visualSettings().enableParticles = !!els.settingParticles.checked; + if (!visualSettings().enableParticles) clearVisualEffectState(); + saveState(); + render(); + }); + els.settingDayNight?.addEventListener('change', () => { + visualSettings().enableDayNight = !!els.settingDayNight.checked; + spriteCache.clear(); + saveState(); + updateClock(); + render(); + }); + [els.modeInspect, els.drawerInspect].filter(Boolean).forEach((el) => el.addEventListener('click', () => setMode('inspect'))); [els.modePlace, els.drawerPlace].filter(Boolean).forEach((el) => el.addEventListener('click', () => setMode('place'))); [els.modeErase, els.drawerErase].filter(Boolean).forEach((el) => el.addEventListener('click', () => setMode('erase'))); @@ -304,6 +357,10 @@ els.voteUp?.addEventListener('click', () => voteSelected(1)); els.voteDown?.addEventListener('click', () => voteSelected(-1)); els.bubbleRemix?.addEventListener('click', () => remixSelected()); + els.bubbleReport?.addEventListener('click', () => openReportDialog()); + els.reportCancel?.addEventListener('click', () => closeReportDialog()); + els.reportSubmit?.addEventListener('click', () => submitReportDialog()); + els.reportDialog?.addEventListener('click', (event) => { if (event.target === els.reportDialog) closeReportDialog(); }); els.bubbleHide?.addEventListener('click', () => hideSelected()); els.paintCanvas.addEventListener('pointerdown', onPaintPointerDown); @@ -327,6 +384,9 @@ els.exportSnapshot?.addEventListener('click', exportSnapshotData); els.exportAssetBundle?.addEventListener('click', exportAssetBundleData); els.resetAll.addEventListener('click', resetAll); + els.validateWorld?.addEventListener('click', validateWorld); + els.exportModerationReport?.addEventListener('click', exportModerationReport); + els.clearReports?.addEventListener('click', clearModerationReports); els.showHiddenAssets?.addEventListener('click', () => { els.hiddenAssetPanel.hidden = !els.hiddenAssetPanel.hidden; renderHiddenAssets(); @@ -421,6 +481,7 @@ animal: 'Animals need ▶ Right and ◀ Left sprites. They hop often and prefer nature.', nature: 'Nature attracts animals and birds. Static sprites are drawn at 2× scale.', building: 'Buildings attract humans. Use Door to mark the entrance.', + ship: 'Ships are static water objects. They float and emit ring ripples.', other: 'Other objects are neutral scenery and render at 2× scale.' }; els.roleHint.textContent = textMap[role] || textMap.other; @@ -485,7 +546,7 @@ function onWorldPointerDown(event) { event.preventDefault(); if (event.button === 2) { - clearWorldSelection(true); + clearWorldSelection(false); pointer.down = false; pointer.id = null; return; @@ -742,26 +803,20 @@ function clearWorldSelection(announce = false) { selectedObject = null; updateSelectionBubble(performance.now()); - if (announce) toast('Selection cleared.'); } function inspectAt(x, y) { const tile = world.get(x, y); - const staticObjects = getPlacedAtTile(x, y); - const dynamicObjects = getDynamicHomesAtTile(x, y); - const picked = [...dynamicObjects.map((p) => ({ ...p, objectKind: 'dynamic' })), ...staticObjects.map((p) => ({ ...p, objectKind: 'static' }))].at(-1); - if (picked) { - selectWorldObject(picked.objectKind, picked.id, picked.assetId, performance.now()); - const asset = findAsset(picked.assetId); - toast(asset ? `Selected ${asset.name}.` : 'Selected object.'); - } else { - selectedObject = null; - updateSelectionBubble(performance.now()); - } + const staticObjects = getPlacedAtTile(x, y).filter((p) => !state.hiddenObjects?.[p.id]); + const dynamicObjects = getDynamicHomesAtTile(x, y).filter((p) => !state.hiddenObjects?.[p.id]); + // Object selection is now based on actual opaque sprite pixels only. + // A plain tile click updates information but does not select hidden/transparent areas. + selectedObject = null; + updateSelectionBubble(performance.now()); const staticNames = staticObjects.map((p) => findAsset(p.assetId)?.name).filter(Boolean); const dynamicNames = dynamicObjects.map((p) => findAsset(p.assetId)?.name).filter(Boolean); const lines = [`Tile ${x}, ${y}`, `Terrain: ${cap(tile.type)}`]; - if (staticNames.length) lines.push(`Static: ${staticNames.join(', ')}`); + if (staticNames.length) lines.push(`Static here: ${staticNames.join(', ')}`); if (dynamicNames.length) lines.push(`Dynamic homes: ${dynamicNames.join(', ')}`); if (els.tileInfo) els.tileInfo.textContent = lines.join('\n'); } @@ -785,6 +840,15 @@ const tile = world.get(x, y); if (!canPlace(asset, tile)) return; + const existingObject = asset.category === 'static' + ? state.placed.find((p) => p.assetId === asset.id) + : state.dynamicSummons.find((p) => p.assetId === asset.id); + const isNewObject = !existingObject; + if (isNewObject && getWorldObjectCount() >= PHASE5_GUARDRAILS.maxWorldObjects) { + toast(`World object limit reached (${PHASE5_GUARDRAILS.maxWorldObjects}). Remove objects before summoning more.`); + return; + } + if (asset.category === 'static') { const existing = state.placed.find((p) => p.assetId === asset.id); if (existing) { @@ -822,8 +886,8 @@ function canPlace(asset, tile) { if (!tile) return false; - if (asset.category === 'static' && asset.subtype === 'water' && tile.type !== 'water') { - toast('Water objects need water tiles.'); + if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship') && tile.type !== 'water') { + toast('Water/ship objects need water tiles.'); return false; } if (asset.category === 'dynamic' && asset.subtype === 'fish' && tile.type !== 'water') { @@ -831,7 +895,7 @@ return false; } if (asset.category !== 'dynamic' || asset.subtype !== 'fish') { - if (tile.type === 'water' && !(asset.category === 'static' && asset.subtype === 'water')) { + if (tile.type === 'water' && !(asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship'))) { toast('Use land for this asset.'); return false; } @@ -1966,6 +2030,10 @@ toast('Draw at least 10 pixels before saving.'); return null; } + if (!existing && state.assets.length >= PHASE5_GUARDRAILS.maxAssets) { + toast(`Asset limit reached (${PHASE5_GUARDRAILS.maxAssets}). Delete or hide unused assets before saving more.`); + return null; + } const name = (els.assetName.value || '').trim() || existing?.name || `${cap(role)} ${state.assets.length + 1}`; const aligned = alignEditorStateToBottom(size); const asset = { @@ -2108,7 +2176,7 @@ } function cycleLibraryFilter() { - const filters = ['all', 'human', 'animal', 'nature', 'building', 'other']; + const filters = ['all', 'human', 'animal', 'nature', 'building', 'ship', 'other']; const index = filters.indexOf(libraryFilter); libraryFilter = filters[(index + 1) % filters.length]; toast(`Library filter: ${libraryFilterLabel()}`); @@ -2154,12 +2222,21 @@ event?.stopPropagation?.(); copyEdit(asset); }); + const move = 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.'); + }); const del = makeButton('Delete', (event) => { event?.stopPropagation?.(); deleteAsset(asset); }); del.classList.add('danger'); - actions.append(up, down, hide, copy, del); + actions.append(up, down, hide, move, copy, del); meta.append(actions); card.append(preview, meta); return card; @@ -2168,37 +2245,109 @@ function renderHiddenAssets() { if (!els.hiddenAssetList) return; els.hiddenAssetList.innerHTML = ''; - const hidden = state.assets.filter((asset) => state.hiddenAssets?.[asset.id]); - if (!hidden.length) { - els.hiddenAssetList.textContent = 'No hidden assets.'; + const hiddenAssets = state.assets.filter((asset) => state.hiddenAssets?.[asset.id]); + const hiddenObjects = collectHiddenObjects(); + if (!hiddenAssets.length && !hiddenObjects.length) { + els.hiddenAssetList.textContent = 'No hidden assets or objects.'; return; } - for (const asset of hidden) { - const row = document.createElement('article'); - row.className = 'assetCard hiddenAssetCard'; - const preview = document.createElement('canvas'); - preview.className = 'assetPreview'; - preview.width = 64; - preview.height = 64; - drawPreview(preview, asset); - const meta = document.createElement('div'); - meta.className = 'assetMeta'; - meta.innerHTML = `Author: ${asset.author || 'Local Artist'}`; - meta.querySelector('strong').textContent = asset.name; - const actions = document.createElement('div'); - actions.className = 'assetActions'; - const restore = makeButton('Show again', (event) => { - event.stopPropagation(); - delete state.hiddenAssets[asset.id]; - saveState(); - renderLibrary(); - toast('Asset shown again.'); - }); - actions.append(restore); - meta.append(actions); - row.append(preview, meta); - els.hiddenAssetList.append(row); + + if (hiddenObjects.length) { + const title = document.createElement('div'); + title.className = 'hiddenSectionTitle'; + title.textContent = 'Hidden map objects'; + els.hiddenAssetList.append(title); + for (const entry of hiddenObjects) els.hiddenAssetList.append(makeHiddenObjectCard(entry)); } + + if (hiddenAssets.length) { + const title = document.createElement('div'); + title.className = 'hiddenSectionTitle'; + title.textContent = 'Hidden library assets'; + els.hiddenAssetList.append(title); + for (const asset of hiddenAssets) els.hiddenAssetList.append(makeHiddenAssetCard(asset)); + } + } + + function collectHiddenObjects() { + const out = []; + const reportsByObject = new Map((state.moderationReports || []).map((report) => [report.objectId, report])); + for (const placed of state.placed || []) { + if (!state.hiddenObjects?.[placed.id]) continue; + const asset = findAsset(placed.assetId); + if (!asset) continue; + out.push({ kind: 'static', object: placed, asset, report: reportsByObject.get(placed.id) || null }); + } + for (const summon of state.dynamicSummons || []) { + if (!state.hiddenObjects?.[summon.id]) continue; + const asset = findAsset(summon.assetId); + if (!asset) continue; + out.push({ kind: 'dynamic', object: summon, asset, report: reportsByObject.get(summon.id) || null }); + } + return out; + } + + function makeHiddenObjectCard(entry) { + const { kind, object, asset, report } = entry; + const row = document.createElement('article'); + row.className = 'assetCard hiddenAssetCard'; + const preview = document.createElement('canvas'); + preview.className = 'assetPreview'; + preview.width = 64; + preview.height = 64; + drawPreview(preview, asset); + const meta = document.createElement('div'); + meta.className = 'assetMeta'; + const location = kind === 'dynamic' + ? `home ${Math.round(object.homeX)},${Math.round(object.homeY)}` + : `tile ${object.x},${object.y}`; + meta.innerHTML = ``; + meta.querySelector('strong').textContent = asset.name; + meta.querySelectorAll('span')[0].textContent = `${cap(kind)} object / ${location}`; + meta.querySelectorAll('span')[1].textContent = report ? `Reported: ${report.reason}` : 'Hidden locally'; + const actions = document.createElement('div'); + actions.className = 'assetActions'; + const restore = makeButton('Show again', (event) => { + event.stopPropagation(); + delete state.hiddenObjects[object.id]; + saveState(); + hydrateRuntime(); + renderLibrary(); + updateSelectionBubble(performance.now()); + toast('Object shown again.'); + }); + actions.append(restore); + meta.append(actions); + row.append(preview, meta); + return row; + } + + function makeHiddenAssetCard(asset) { + const row = document.createElement('article'); + row.className = 'assetCard hiddenAssetCard'; + const preview = document.createElement('canvas'); + preview.className = 'assetPreview'; + preview.width = 64; + preview.height = 64; + drawPreview(preview, asset); + const meta = document.createElement('div'); + meta.className = 'assetMeta'; + meta.innerHTML = `Author: ${asset.author || 'Local Artist'}`; + meta.querySelector('strong').textContent = asset.name; + const actions = document.createElement('div'); + actions.className = 'assetActions'; + const restore = makeButton('Show again', (event) => { + event.stopPropagation(); + delete state.hiddenAssets[asset.id]; + saveState(); + renderLibrary(); + updateSelectionBubble(performance.now()); + toast('Asset shown again.'); + }); + actions.append(restore); + meta.append(actions); + row.append(preview, meta); + return row; } function drawPreview(canvas, asset) { @@ -2241,7 +2390,7 @@ } function displayCategory(asset) { - return asset.category === 'dynamic' ? 'People & Animals' : 'Buildings & Nature'; + return asset.category === 'dynamic' ? 'People & Animals' : (asset.subtype === 'ship' ? 'Ships' : 'Buildings & Nature'); } function makeButton(label, onClick) { @@ -2316,7 +2465,8 @@ hiddenUntil: 0, seed: Math.random() * 9999, nextDecisionAt: 0, - nextBubbleAt: 800 + Math.random() * 1500 + nextBubbleAt: 800 + Math.random() * 1500, + nextStepParticleAt: performance.now() + 110 + Math.random() * 160 }; }).filter(Boolean); } @@ -2346,11 +2496,13 @@ function updateDynamicRuntime(dt, time) { bubbleParticles = bubbleParticles.filter((p) => time - p.started < p.life); confettiParticles = confettiParticles.filter((p) => time - p.started < p.life); + landStepParticles = landStepParticles.filter((p) => time - p.started < p.life); + natureDriftParticles = natureDriftParticles.filter((p) => time - p.started < p.life); for (const item of dynamicRuntime) { const asset = findAsset(item.assetId); if (!asset) continue; if (time < item.hiddenUntil) continue; - if (asset.subtype === 'fish' && time > (item.nextBubbleAt || 0)) { + if (visualSettings().enableParticles && asset.subtype === 'fish' && time > (item.nextBubbleAt || 0)) { spawnFishBubbleCluster(item.x, item.y, time, item.seed); item.nextBubbleAt = time + 1100 + Math.random() * 1800; } @@ -2375,6 +2527,14 @@ item.x += (dx / len) * step; item.y += (dy / len) * step; + if (visualSettings().enableParticles && asset.subtype !== 'fish' && asset.subtype !== 'bird' && time >= (item.nextStepParticleAt || 0)) { + const groundTile = world.get(clamp(Math.floor(item.x), 0, WORLD_W - 1), clamp(Math.floor(item.y), 0, WORLD_H - 1)); + if (groundTile && groundTile.type !== 'water') { + spawnGroundStepParticles(item, time, groundTile); + } + item.nextStepParticleAt = time + 110 + Math.random() * 140; + } + if (asset.subtype === 'human' && distance < .3 && Math.random() < .004) { item.hiddenUntil = time + 1700 + Math.random() * 2200; } @@ -2502,65 +2662,79 @@ } function getPhase() { + if (visualSettings().enableDayNight === false) { + return { key: 'day', label: 'Day', progress: 0.25, sky: '#86d5ff', darkness: 0, tint: 'rgba(255,255,255,0)', tintAlpha: 0, shadow: getShadowForMinute(3) }; + } const t = mod(Date.now(), DAY_MS); const minute = t / 60000; const stops = [ - { at: 0, key: 'morning', label: 'Morning', darkness: 0.18, tint: [255, 208, 144, 0.12] }, - { at: 1, key: 'day', label: 'Day', darkness: 0.00, tint: [255, 255, 255, 0.00] }, - { at: 5, key: 'day', label: 'Day', darkness: 0.00, tint: [255, 255, 255, 0.00] }, - { at: 6, key: 'evening', label: 'Evening', darkness: 0.18, tint: [255, 146, 114, 0.14] }, - { at: 10, key: 'night', label: 'Night', darkness: 0.68, tint: [18, 25, 64, 0.30] } + // Fast dawn: sky shifts quickly, then stays stable through the day. + { at: 0.00, key: 'morning', label: 'Morning', sky: '#f6b06f', darkness: 0.22, tint: [255, 204, 132, 0.16] }, + { at: 0.55, key: 'day', label: 'Day', sky: '#86d5ff', darkness: 0.00, tint: [255, 255, 255, 0.00] }, + { at: 5.00, key: 'day', label: 'Day', sky: '#86d5ff', darkness: 0.00, tint: [255, 255, 255, 0.00] }, + // Fast dusk: the main sky movement happens here. + { at: 5.55, key: 'evening', label: 'Evening', sky: '#f09076', darkness: 0.22, tint: [255, 128, 96, 0.15] }, + { at: 6.10, key: 'night', label: 'Night', sky: '#182a58', darkness: 0.58, tint: [14, 22, 54, 0.10] }, + { at: 10.00, key: 'night', label: 'Night', sky: '#182a58', darkness: 0.58, tint: [14, 22, 54, 0.10] } ]; let a = stops[0], b = stops[1]; for (let i = 0; i < stops.length - 1; i++) { if (minute >= stops[i].at && minute < stops[i + 1].at) { a = stops[i]; b = stops[i + 1]; break; } - if (minute >= 6) { a = stops[3]; b = stops[4]; } } const localT = clamp((minute - a.at) / Math.max(0.0001, b.at - a.at), 0, 1); const eased = localT * localT * (3 - 2 * localT); const tint = a.tint.map((v, i) => lerp(v, b.tint[i], eased)); - const label = minute < 1 ? 'Morning' : minute < 5 ? 'Day' : minute < 6 ? 'Evening' : 'Night'; + const label = minute < 0.55 ? 'Morning' : minute < 5 ? 'Day' : minute < 6.10 ? 'Evening' : 'Night'; return { key: label.toLowerCase(), label, progress: t / DAY_MS, + sky: mixHex(a.sky, b.sky, eased), darkness: lerp(a.darkness, b.darkness, eased), tint: `rgba(${Math.round(tint[0])}, ${Math.round(tint[1])}, ${Math.round(tint[2])}, ${tint[3].toFixed(3)})`, + tintAlpha: tint[3], shadow: getShadowForMinute(minute) }; } function render(time = performance.now()) { - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - ctx.clearRect(0, 0, cw, ch); - ctx.fillStyle = '#86d5ff'; - ctx.fillRect(0, 0, cw, ch); const phase = getPhase(); renderPhase = phase; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, cw, ch); + ctx.fillStyle = phase.sky || '#86d5ff'; + ctx.fillRect(0, 0, cw, ch); ctx.save(); ctx.translate(view.x, view.y); ctx.scale(view.zoom, view.zoom); ctx.imageSmoothingEnabled = false; drawTerrainCache(terrainCache); + if (visualSettings().enableParticles) drawCoastalFoam(time); drawHoverTile(); const lightSources = []; - drawObjects(time, lightSources, phase); - drawBubbleParticles(time); - drawSpawnEffects(time); - drawConfettiParticles(time); + const visibleItems = drawObjects(time, lightSources, phase); + if (visualSettings().enableParticles) { + spawnNatureAmbientParticles(visibleItems, time); + drawNatureDriftParticles(time); + drawLandStepParticles(time); + drawBubbleParticles(time); + drawSpawnEffects(time); + drawConfettiParticles(time); + } + if (visualSettings().enableLights) drawSurfaceLightBleed(visibleItems, lightSources, time); ctx.restore(); updateSelectionBubble(time); - if (phase.tint !== 'rgba(255, 255, 255, 0)') { + if ((phase.tintAlpha || 0) > 0.001) { ctx.fillStyle = phase.tint; ctx.fillRect(0, 0, cw, ch); } if (phase.darkness > 0) { ctx.fillStyle = `rgba(12, 19, 45, ${phase.darkness})`; ctx.fillRect(0, 0, cw, ch); - drawLightSources(lightSources, phase.darkness); } + if (visualSettings().enableLights && phase.darkness > 0) drawLightSources(lightSources, phase.darkness); } function drawHoverTile() { @@ -2615,6 +2789,7 @@ drawSpriteItem(item, time, lightSources, false, phase); } } + return items; } function getSpriteDrawInfo(item, time, includeSelectBounce = true) { @@ -2630,7 +2805,7 @@ let alpha = 1; let side = 'right'; let angle = 0; - if (asset.category === 'static' && asset.subtype === 'water') bob += Math.sin(time / 900 + item.x * .7) * 1.6; + if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship')) bob += Math.sin(time / 900 + item.x * .7) * 1.6; if (asset.category === 'dynamic') { const runtime = item.source; side = (runtime.facing || runtime.vx || 1) < 0 ? 'left' : 'right'; @@ -2653,13 +2828,16 @@ } const sprite = getSpriteCanvas(asset, side); const drawX = pos.x - sprite.width / 2; - const drawY = pos.y + TILE_H / 2 - sprite.height + bob; - return { asset, pos, sprite, drawX, drawY, alpha, angle, side, bob }; + 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 }; } function drawSpriteItem(item, time, lightSources, underwater, phase) { const { asset, pos, sprite, drawX, drawY, alpha, angle, side } = getSpriteDrawInfo(item, time, true); + if (visualSettings().enableParticles && asset.category === 'static' && asset.subtype === 'ship') drawShipRipples(pos, time, item.source?.id || asset.id); + if (!(asset.category === 'dynamic' && asset.subtype === 'fish')) drawSpriteShadow(drawX, drawY, sprite, phase); ctx.save(); @@ -2684,11 +2862,156 @@ const lx = asset.category === 'dynamic' && side === 'left' ? asset.size - 1 - light.x : light.x; const wx = drawX + (lx + 0.5) * scale; const wy = drawY + (light.y + 0.5) * scale; - lightSources.push({ x: wx, y: wy, color: colorToHex(light.c || asset.meta.lightColor || nearestPaletteCode('#ffd86a')), radius: lerp(12, 20, asset.size / 64) }); + lightSources.push({ x: wx, y: wy, color: colorToHex(light.c || asset.meta.lightColor || nearestPaletteCode('#ffd86a')), radius: lerp(10, 16, asset.size / 64), ownerId: item.source?.id || asset.id }); } } } + + function drawSurfaceLightBleed(items, lightSources, time) { + if (!items?.length || !lightSources?.length) return; + for (const item of items) { + const info = getSpriteDrawInfo(item, time, false); + const sprite = info.sprite; + const localSources = []; + for (const source of lightSources) { + if (source.ownerId === item.source?.id) continue; + const dx = source.x - (info.drawX + sprite.width / 2); + const dy = source.y - (info.drawY + sprite.height / 2); + const reach = source.radius * 2.2 + Math.max(sprite.width, sprite.height) * 0.55; + const distance = Math.hypot(dx, dy); + if (distance > reach) continue; + localSources.push({ source, distance }); + } + if (!localSources.length) continue; + localSources.sort((a, b) => a.distance - b.distance); + const overlay = document.createElement('canvas'); + overlay.width = sprite.width; + overlay.height = sprite.height; + const octx = overlay.getContext('2d'); + octx.imageSmoothingEnabled = false; + octx.drawImage(sprite, 0, 0); + octx.globalCompositeOperation = 'source-atop'; + for (const { source, distance } of localSources.slice(0, 2)) { + const localX = source.x - info.drawX; + const localY = source.y - info.drawY; + const radius = Math.max(12, source.radius * 1.7); + const influence = 1 - clamp(distance / (source.radius * 2.4 + 36), 0, 1); + if (influence <= 0.02) continue; + const grad = octx.createRadialGradient(localX, localY, 0, localX, localY, radius); + grad.addColorStop(0, hexToRgba(source.color, 0.22 * influence)); + grad.addColorStop(0.5, hexToRgba(source.color, 0.10 * influence)); + grad.addColorStop(1, 'rgba(255,255,255,0)'); + octx.fillStyle = grad; + octx.fillRect(0, 0, overlay.width, overlay.height); + } + ctx.save(); + ctx.globalAlpha = 0.9; + if (info.angle) { + ctx.translate(Math.round(info.drawX + sprite.width / 2), Math.round(info.drawY + sprite.height * 0.8)); + ctx.rotate(info.angle); + ctx.drawImage(overlay, Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8)); + } else { + ctx.drawImage(overlay, Math.round(info.drawX), Math.round(info.drawY)); + } + ctx.restore(); + } + } + + function spawnGroundStepParticles(item, time, tile) { + const baseColor = getTerrainSurfaceColorAt(item.x, item.y); + const count = 1 + (Math.random() < 0.5 ? 1 : 0); + for (let i = 0; i < count; i++) { + landStepParticles.push({ + x: item.x + (Math.random() - 0.5) * 0.18, + y: item.y + 0.16 + (Math.random() - 0.5) * 0.05, + vx: (Math.random() - 0.5) * 0.045 - (item.vx || 0) * 0.012, + vy: -0.015 - Math.random() * 0.02, + started: time + i * 18, + life: 260 + Math.random() * 180, + color: varyHexColor(baseColor, 26, 16, 12), + tileType: tile?.type || 'grass' + }); + } + } + + function drawLandStepParticles(time) { + if (!landStepParticles.length) return; + ctx.save(); + for (const particle of landStepParticles) { + const age = (time - particle.started) / particle.life; + if (age < 0 || age > 1) continue; + const px = particle.x + particle.vx * age * 18; + const py = particle.y + particle.vy * age * 18; + const pos = tileToWorld(px, py); + pos.y -= getLiftAtCoord(px, py); + ctx.globalAlpha = (1 - age) * 0.7; + ctx.fillStyle = particle.color; + const size = age < 0.28 ? 2 : 1; + ctx.fillRect(Math.round(pos.x), Math.round(pos.y + TILE_H * 0.22 - age * 3), size, size); + } + ctx.restore(); + } + + function spawnNatureAmbientParticles(items, time) { + if (natureDriftParticles.length > 120) return; + const natureItems = items.filter((item) => item.asset.category === 'static' && item.asset.subtype === 'nature'); + for (const item of natureItems) { + if (Math.random() > 0.025) continue; + const info = getSpriteDrawInfo(item, time, false); + natureDriftParticles.push({ + x: item.x + (Math.random() - 0.5) * 0.55, + y: item.y - 0.15 - Math.random() * 0.7, + vx: -0.012 + Math.random() * 0.024, + vy: 0.018 + Math.random() * 0.024, + started: time, + life: 1800 + Math.random() * 1800, + color: Math.random() < 0.55 ? ['#7abf64', '#99d57b', '#f4a1bb', '#ffd56e'][Math.floor(Math.random() * 4)] : ['#f7c3d7', '#ffef8b', '#8bd68f'][Math.floor(Math.random() * 3)], + sway: Math.random() * Math.PI * 2, + originDrawY: info.drawY + }); + } + } + + function drawNatureDriftParticles(time) { + if (!natureDriftParticles.length) return; + ctx.save(); + for (const particle of natureDriftParticles) { + const age = (time - particle.started) / particle.life; + if (age < 0 || age > 1) continue; + const sway = Math.sin(age * 5.5 + particle.sway) * 0.08; + const px = particle.x + particle.vx * age * 22 + sway; + const py = particle.y + particle.vy * age * 22; + const pos = tileToWorld(px, py); + pos.y -= getLiftAtCoord(px, py); + ctx.globalAlpha = (1 - age) * 0.72; + ctx.fillStyle = particle.color; + ctx.fillRect(Math.round(pos.x), Math.round(pos.y - age * 5), 1, 1); + } + ctx.restore(); + } + + + function drawShipRipples(pos, time, seedValue = '') { + const seed = fnv1a(String(seedValue)).slice(0, 6); + const numericSeed = parseInt(seed, 16) || 1; + const cycleMs = 2300; + const cycle = Math.floor((time + numericSeed) / cycleMs); + if (pseudoNoise(cycle + numericSeed * 0.013) < 0.38) return; + const t = ((time + numericSeed) % cycleMs) / cycleMs; + const alpha = Math.max(0, 0.36 * (1 - t)); + const rx = 5 + t * 22; + const ry = 2 + t * 8; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.strokeStyle = '#e8fbff'; + ctx.lineWidth = Math.max(1, 1.5 / view.zoom); + ctx.beginPath(); + ctx.ellipse(Math.round(pos.x), Math.round(pos.y + TILE_H * .15), rx, ry, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } + function drawFishBubbles(x, y, time, seed) { const cycle = Math.floor((time + seed * 37) / 1300); if (pseudoNoise(cycle + seed) < 0.55) return; @@ -2854,6 +3177,11 @@ els.voteUp.classList.toggle('mutedVote', previous < 0); els.voteDown.classList.toggle('mutedVote', previous > 0); if (els.bubbleHide) els.bubbleHide.hidden = previous >= 0; + if (els.bubbleReport) { + const reported = hasReportForObject(objectId); + els.bubbleReport.disabled = reported; + els.bubbleReport.textContent = reported ? 'Reported' : 'Report'; + } } function voteSelected(delta) { @@ -2904,6 +3232,77 @@ // Server moderation TODO: when synced, repeated low-rating + hide actions can flag this object for review. } + function hasReportForObject(objectId) { + const reporter = currentVoterKey(); + return Array.isArray(state.moderationReports) + && state.moderationReports.some((report) => report.objectId === objectId && report.reporter === reporter); + } + + function openReportDialog() { + if (!selectedObject) return; + if (hasReportForObject(selectedObject.id)) { + toast('Already reported locally.'); + updateSelectionBubble(performance.now()); + return; + } + const asset = findAsset(selectedObject.assetId); + pendingReport = { ...selectedObject }; + if (els.reportObjectName) els.reportObjectName.textContent = asset ? asset.name : 'selected object'; + if (els.reportReason) els.reportReason.value = 'inappropriate'; + if (els.reportDialog) { + els.reportDialog.hidden = false; + els.reportReason?.focus?.(); + } else { + submitReport('local-report'); + } + } + + function closeReportDialog() { + pendingReport = null; + if (els.reportDialog) els.reportDialog.hidden = true; + } + + function submitReportDialog() { + const reason = els.reportReason?.value || 'local-report'; + submitReport(reason); + } + + function submitReport(reason = 'local-report') { + const target = pendingReport || selectedObject; + if (!target) return; + const asset = findAsset(target.assetId); + state.moderationReports = normalizeModerationReports(state.moderationReports || []); + state.settings = { ...defaultVisualSettings(), ...(state.settings || {}) }; + if (hasReportForObject(target.id)) { + closeReportDialog(); + toast('Already reported locally.'); + updateSelectionBubble(performance.now()); + return; + } + const report = { + id: uid(), + objectId: target.id, + assetId: target.assetId, + objectKind: target.kind, + assetName: asset?.name || 'Untitled', + assetAuthor: asset?.author || 'Local Artist', + reporter: currentVoterKey(), + reason, + createdAt: Date.now() + }; + state.moderationReports.push(report); + state.moderationReports = state.moderationReports.slice(-PHASE5_GUARDRAILS.maxReports); + state.hiddenObjects ||= {}; + state.hiddenObjects[target.id] = true; + if (selectedObject?.id === target.id) selectedObject = null; + closeReportDialog(); + saveState(); + hydrateRuntime(); + updateSelectionBubble(performance.now()); + renderLibrary(); + toast('Reported and hidden locally.'); + } + function spawnFishBubbleCluster(tileX, tileY, time, seed = 0) { const count = 2 + Math.floor(pseudoNoise(time * 0.003 + seed) * 3); for (let i = 0; i < count; i++) { @@ -2970,26 +3369,40 @@ function drawLightSources(sources, darkness) { ctx.save(); - ctx.globalCompositeOperation = 'lighter'; for (const source of sources) { const sx = source.x * view.zoom + view.x; const sy = source.y * view.zoom + view.y; const radius = source.radius * view.zoom; - if (sx < -radius || sy < -radius || sx > cw + radius || sy > ch + radius) continue; - const gradient = ctx.createRadialGradient(sx, sy, 0, sx, sy, radius); - gradient.addColorStop(0, hexToRgba(source.color, .18 + darkness * .10)); - gradient.addColorStop(.35, hexToRgba(source.color, .08)); - gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); - ctx.fillStyle = gradient; + const auraRadius = radius * 1.28; + if (sx < -auraRadius || sy < -auraRadius || sx > cw + auraRadius || sy > ch + auraRadius) continue; + + // Light cores are drawn after the night overlay and do not inherit night darkness. + ctx.globalCompositeOperation = 'source-over'; + const core = ctx.createRadialGradient(sx, sy, 0, sx, sy, Math.max(3, radius * 0.48)); + core.addColorStop(0, hexToRgba(source.color, .84)); + core.addColorStop(.34, hexToRgba(source.color, .44)); + core.addColorStop(1, hexToRgba(source.color, .00)); + ctx.fillStyle = core; ctx.beginPath(); - ctx.arc(sx, sy, radius, 0, Math.PI * 2); + ctx.arc(sx, sy, Math.max(3, radius * 0.48), 0, Math.PI * 2); + ctx.fill(); + + ctx.globalCompositeOperation = 'lighter'; + const glow = ctx.createRadialGradient(sx, sy, 0, sx, sy, auraRadius); + glow.addColorStop(0, hexToRgba(source.color, .22)); + glow.addColorStop(.42, hexToRgba(source.color, .10)); + glow.addColorStop(1, 'rgba(255, 255, 255, 0)'); + ctx.fillStyle = glow; + ctx.beginPath(); + ctx.arc(sx, sy, auraRadius, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } function getSpriteCanvas(asset, side) { - const key = `${asset.id}:${side}:${asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE}`; + const phaseKey = renderPhase ? `${renderPhase.key}:${Math.round((renderPhase.darkness || 0) * 10)}` : 'day'; + const key = `${asset.id}:${side}:${asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE}:${phaseKey}`; if (spriteCache.has(key)) return spriteCache.get(key); const pixels = getAssetPixels(asset, side); const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; @@ -3002,7 +3415,7 @@ for (let x = 0; x < asset.size; x++) { const color = pixels[y * asset.size + x]; if (!color) continue; - c.fillStyle = applyDepthToColor(colorToHex(color), getAssetDepth(asset, x, y, side)); + c.fillStyle = shadeAssetPixelColor(colorToHex(color), getAssetDepth(asset, x, y, side), x, y, asset.size, renderPhase); c.fillRect(x * scale, y * scale, scale, scale); } } @@ -3088,8 +3501,27 @@ maxY = Math.max(maxY, bounds.y + canvas.height); } } - if (!chunks.length) return { chunks: [], bounds: { x: 0, y: 0, w: 1, h: 1 }, chunkSize }; - return { chunks, bounds: { x: minX, y: minY, w: maxX - minX, h: maxY - minY }, chunkSize }; + const coastTiles = findCoastFoamTiles(worldData); + if (!chunks.length) return { chunks: [], bounds: { x: 0, y: 0, w: 1, h: 1 }, chunkSize, coastTiles }; + return { chunks, bounds: { x: minX, y: minY, w: maxX - minX, h: maxY - minY }, chunkSize, coastTiles }; + } + + function findCoastFoamTiles(worldData) { + const out = []; + const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; + for (let y = 0; y < WORLD_H; y++) { + for (let x = 0; x < WORLD_W; x++) { + const tile = worldData.get(x, y); + if (!tile || tile.type !== 'water') continue; + let touchesLand = false; + for (const [dx, dy] of dirs) { + const near = worldData.get(x + dx, y + dy); + if (near && near.type !== 'water') { touchesLand = true; break; } + } + if (touchesLand) out.push({ x, y }); + } + } + return out; } function getTerrainChunkBounds(tiles) { @@ -3118,6 +3550,57 @@ } + function buildCoastalFoamTextures() { + return { a: makeFoamTexture(24, 1), b: makeFoamTexture(24, 2) }; + } + + function makeFoamTexture(size, seedOffset) { + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const c = canvas.getContext('2d', { alpha: true }); + c.clearRect(0, 0, size, size); + for (let i = 0; i < 26; i++) { + const n = pseudoNoise(seedOffset * 93 + i * 17.3); + const x = Math.floor(n * (size - 6)) + 3; + const y = Math.floor(pseudoNoise(seedOffset * 77 + i * 11.1) * (size - 6)) + 3; + const r = 1 + Math.floor(pseudoNoise(seedOffset * 41 + i * 6.2) * 3); + c.fillStyle = i % 2 ? 'rgba(232, 248, 255, 0.42)' : 'rgba(216, 239, 248, 0.32)'; + c.beginPath(); + c.arc(x, y, r, 0, Math.PI * 2); + c.fill(); + c.globalCompositeOperation = 'destination-out'; + c.beginPath(); + c.arc(x + (i % 2 ? 0.4 : -0.6), y + (i % 3 ? 0.2 : -0.4), Math.max(0.6, r - 1.0), 0, Math.PI * 2); + c.fill(); + c.globalCompositeOperation = 'source-over'; + } + return canvas; + } + + function drawCoastalFoam(time) { + if (!terrainCache?.coastTiles?.length || !coastalFoamTextures) return; + const rect = getViewportWorldRect(64); + for (const tile of terrainCache.coastTiles) { + const pos = tileToWorld(tile.x, tile.y); + if (pos.x + 22 < rect.left || pos.x - 22 > rect.right || pos.y + 18 < rect.top || pos.y - 18 > rect.bottom) continue; + const baseY = pos.y + TILE_H * 0.42; + ctx.save(); + ctx.globalAlpha = 0.34; + ctx.translate(pos.x, baseY); + ctx.rotate(time / 12000 + (tile.x + tile.y) * 0.03); + ctx.drawImage(coastalFoamTextures.a, -12, -12); + ctx.restore(); + ctx.save(); + ctx.globalAlpha = 0.22; + ctx.translate(pos.x + 1, baseY + 1); + ctx.rotate(-(time / 16500) + (tile.x - tile.y) * 0.025); + ctx.drawImage(coastalFoamTextures.b, -12, -12); + ctx.restore(); + } + } + + function drawTerrainTile(c, tile, worldData) { const { x, y } = tileToWorld(tile.x, tile.y); const palette = { @@ -3197,6 +3680,17 @@ return getTileLift(tile); } + function getTerrainSurfaceColorAt(x, y) { + const tile = world.get(clamp(Math.floor(x), 0, WORLD_W - 1), clamp(Math.floor(y), 0, WORLD_H - 1)); + const palette = { + water: '#8cd2ee', + sand: '#ead99d', + grass: '#91cf76', + highland: '#9db781' + }; + return palette[tile?.type] || '#d9d2c0'; + } + function loadState() { try { const raw = localStorage.getItem(STORAGE_KEY); @@ -3213,11 +3707,15 @@ function saveState() { state.schema = SAVE_SCHEMA; + state.guardrails = { ...PHASE5_GUARDRAILS, ...(state.guardrails || {}) }; + state.moderationReports = normalizeModerationReports(state.moderationReports || []); + state.settings = { ...defaultVisualSettings(), ...(state.settings || {}) }; state.eventLog = Array.isArray(state.eventLog) ? state.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)) : []; const payload = Phase2Sync?.compactState ? Phase2Sync.compactState(state) : state; localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); cachePhase2State(); updateSyncStats(); + updateGuardrailStats(); } function normalizeState(input) { @@ -3231,8 +3729,11 @@ assetVotes: input.assetVotes || {}, hiddenAssets: input.hiddenAssets || {}, hiddenObjects: input.hiddenObjects || {}, + moderationReports: normalizeModerationReports(input.moderationReports || []), + guardrails: { ...PHASE5_GUARDRAILS, ...(input.guardrails || {}) }, eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)) : [], - sync: input.sync || { lastEventId: null } + sync: input.sync || { lastEventId: null }, + settings: { ...defaultVisualSettings(), ...(input.settings || {}) } }; } @@ -3342,34 +3843,41 @@ function seedState() { const assets = [ - makeAsset('Cozy House', 'static', 'building', drawHouse(), { hasLight: true, lightPixels: [{ x: 10, y: 8 }, { x: 11, y: 8 }], lightColor: '#ffd86a', door: { x: 7, y: 14 } }), - makeAsset('Round Tree', 'static', 'nature', drawTree()), - makeAsset('Dock Lamp', 'static', 'water', drawLantern(), { hasLight: true, lightPixels: [{ x: 7, y: 6 }, { x: 8, y: 6 }], lightColor: '#b8e8ff' }), - makeAsset('Traveler', 'dynamic', 'human', drawHumanRight(), {}, drawHumanLeft()), - makeAsset('Island Pup', 'dynamic', 'animal', drawDogRight(), {}, drawDogLeft()), - makeAsset('Blue Fish', 'dynamic', 'fish', drawFishRight(), {}, drawFishLeft()), - makeAsset('Tiny Bird', 'dynamic', 'bird', drawBirdRight(), {}, drawBirdLeft()) + makeAsset('Hill Cottage', 'static', 'building', drawCottage(), { hasLight: true, lightPixels: [{ x: 8, y: 8 }, { x: 9, y: 8 }], lightColor: '#ffd86a', door: { x: 7, y: 14 } }), + makeAsset('Pine Cluster', 'static', 'nature', drawPineCluster()), + makeAsset('Shell Rock', 'static', 'nature', drawShellRock()), + makeAsset('Wave Skiff', 'static', 'ship', drawShip(), { depthPixels: drawShipDepth(), hasLight: false }), + makeAsset('Fisher Kid', 'dynamic', 'human', drawFisherKidRight()), + makeAsset('Moss Cat', 'dynamic', 'animal', drawCatRight()), + makeAsset('Koi Fish', 'dynamic', 'fish', drawKoiRight()), + makeAsset('Cloud Gull', 'dynamic', 'bird', drawGullRight()) ]; const idByName = Object.fromEntries(assets.map((a) => [a.name, a.id])); + const shipPos = findNearestTerrain('water', 62, 58); + const fishPos = findNearestTerrain('water', 66, 60); return { schema: SAVE_SCHEMA, authorName: 'Local Artist', assets, placed: [ - { id: uid(), assetId: idByName['Cozy House'], x: 36, y: 36, placedAt: Date.now() }, - { id: uid(), assetId: idByName['Round Tree'], x: 32, y: 36, placedAt: Date.now() }, - { id: uid(), assetId: idByName['Dock Lamp'], ...findNearestTerrain('water', 62, 58), placedAt: Date.now() } + { id: uid(), assetId: idByName['Hill Cottage'], x: 36, y: 36, placedAt: Date.now(), version: 1 }, + { id: uid(), assetId: idByName['Pine Cluster'], x: 32, y: 36, placedAt: Date.now(), version: 1 }, + { id: uid(), assetId: idByName['Shell Rock'], x: 39, y: 40, placedAt: Date.now(), version: 1 }, + { id: uid(), assetId: idByName['Wave Skiff'], ...shipPos, placedAt: Date.now(), version: 1 } ], objectVotes: {}, assetVotes: {}, hiddenAssets: {}, hiddenObjects: {}, + moderationReports: [], + guardrails: { ...PHASE5_GUARDRAILS }, + settings: defaultVisualSettings(), dynamicSummons: [ - { id: uid(), assetId: idByName['Traveler'], homeX: 36, homeY: 38, createdAt: Date.now() }, - { id: uid(), assetId: idByName['Island Pup'], homeX: 32, homeY: 40, createdAt: Date.now() }, - { id: uid(), assetId: idByName['Blue Fish'], ...homeFromPos(findNearestTerrain('water', 63, 60)), createdAt: Date.now() }, - { id: uid(), assetId: idByName['Tiny Bird'], homeX: 90, homeY: 30, createdAt: Date.now() } + { id: uid(), assetId: idByName['Fisher Kid'], homeX: 36, homeY: 38, createdAt: Date.now(), version: 1 }, + { id: uid(), assetId: idByName['Moss Cat'], homeX: 33, homeY: 40, createdAt: Date.now(), version: 1 }, + { id: uid(), assetId: idByName['Koi Fish'], ...homeFromPos(fishPos), createdAt: Date.now(), version: 1 }, + { id: uid(), assetId: idByName['Cloud Gull'], homeX: 90, homeY: 30, createdAt: Date.now(), version: 1 } ] }; } @@ -3515,6 +4023,118 @@ } } + function getWorldObjectCount() { + return (state.placed?.length || 0) + (state.dynamicSummons?.length || 0); + } + + function normalizeModerationReports(input) { + const list = Array.isArray(input) ? input : []; + return list + .map((report) => ({ + id: report.id || uid(), + objectId: report.objectId || report.id || '', + assetId: report.assetId || '', + objectKind: report.objectKind === 'dynamic' ? 'dynamic' : 'static', + assetName: report.assetName || 'Untitled', + assetAuthor: report.assetAuthor || 'Local Artist', + reporter: report.reporter || 'local', + reason: report.reason || 'local-report', + createdAt: Number(report.createdAt) || Date.now() + })) + .filter((report) => report.objectId && report.assetId) + .slice(-PHASE5_GUARDRAILS.maxReports); + } + + function collectWorldValidation() { + const assetIds = new Set((state.assets || []).map((asset) => asset.id)); + const contentCounts = new Map(); + for (const asset of state.assets || []) { + const key = asset.contentHash || computeAssetContentHash(asset); + contentCounts.set(key, (contentCounts.get(key) || 0) + 1); + } + const orphanStatic = (state.placed || []).filter((item) => !assetIds.has(item.assetId)); + const orphanDynamic = (state.dynamicSummons || []).filter((item) => !assetIds.has(item.assetId)); + const invalidTerrain = []; + for (const item of state.placed || []) { + const asset = findAsset(item.assetId); + const tile = world.get(item.x, item.y); + if (asset && tile && !isTerrainCompatible(asset, tile)) invalidTerrain.push({ kind: 'static', id: item.id, assetId: item.assetId, x: item.x, y: item.y }); + } + for (const item of state.dynamicSummons || []) { + const asset = findAsset(item.assetId); + const tile = world.get(Math.round(item.homeX), Math.round(item.homeY)); + if (asset && tile && !isTerrainCompatible(asset, tile)) invalidTerrain.push({ kind: 'dynamic', id: item.id, assetId: item.assetId, x: item.homeX, y: item.homeY }); + } + const duplicateContent = [...contentCounts.values()].filter((count) => count > 1).reduce((sum, count) => sum + count - 1, 0); + return { + schema: SAVE_SCHEMA, + generatedAt: new Date().toISOString(), + assets: state.assets?.length || 0, + worldObjects: getWorldObjectCount(), + hiddenObjects: Object.keys(state.hiddenObjects || {}).length, + reports: state.moderationReports?.length || 0, + duplicateContent, + orphanStatic: orphanStatic.length, + orphanDynamic: orphanDynamic.length, + invalidTerrain: invalidTerrain.length, + invalidTerrainObjects: invalidTerrain, + limits: state.guardrails || PHASE5_GUARDRAILS + }; + } + + function isTerrainCompatible(asset, tile) { + if (!asset || !tile) return false; + if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship')) return tile.type === 'water'; + if (asset.category === 'dynamic' && asset.subtype === 'fish') return tile.type === 'water'; + return tile.type !== 'water'; + } + + function updateGuardrailStats() { + if (!els.guardrailStats) return; + const report = collectWorldValidation(); + const assetText = `${report.assets}/${report.limits.maxAssets}`; + const objectText = `${report.worldObjects}/${report.limits.maxWorldObjects}`; + const warnings = report.orphanStatic + report.orphanDynamic + report.invalidTerrain; + els.guardrailStats.textContent = `Assets ${assetText}, objects ${objectText}, reports ${report.reports}, hidden ${report.hiddenObjects}, duplicate-content copies ${report.duplicateContent}, validation warnings ${warnings}.`; + } + + function validateWorld() { + const report = collectWorldValidation(); + els.dataBox.value = JSON.stringify({ format: 'pixel-island-phase5-validation-v1', ...report }, null, 2); + updateGuardrailStats(); + const warnings = report.orphanStatic + report.orphanDynamic + report.invalidTerrain; + toast(warnings ? `Validation found ${warnings} warning(s).` : 'Validation passed.'); + } + + function exportModerationReport() { + const validation = collectWorldValidation(); + const payload = { + format: 'pixel-island-phase5-moderation-v1', + generatedAt: validation.generatedAt, + authorName: state.authorName || 'Local Artist', + reports: normalizeModerationReports(state.moderationReports || []), + hiddenObjects: state.hiddenObjects || {}, + downvotedObjects: Object.entries(state.objectVotes || {}) + .map(([objectId, votes]) => ({ objectId, up: votes.up || 0, down: votes.down || 0, score: (votes.up || 0) - (votes.down || 0) })) + .filter((item) => item.down > 0), + validation + }; + els.dataBox.value = JSON.stringify(payload, null, 2); + toast('Moderation report exported.'); + } + + function clearModerationReports() { + if (!state.moderationReports?.length) { + toast('No local reports to clear.'); + return; + } + if (!confirm('Clear local moderation reports? Hidden objects stay hidden.')) return; + state.moderationReports = []; + saveState(); + updateGuardrailStats(); + toast('Local reports cleared.'); + } + function updateSyncStats() { if (!els.syncStats) return; const report = Phase2Sync?.compactSizeReport?.(state); @@ -3669,10 +4289,20 @@ } function applyDepthToColor(hex, depth) { - if (!depth) return hex; + return shadeAssetPixelColor(hex, depth, 0, 0, 1, renderPhase); + } + + function shadeAssetPixelColor(hex, depth, x, y, size, phase) { const rgb = parseHex(hex); if (!rgb) return hex; - const amt = depth > 0 ? 34 : -34; + const activePhase = phase || renderPhase || getPhase(); + const dirX = clamp(-(activePhase?.shadow?.dirX || 0), -1, 1); + const vertical = 1 - (y / Math.max(1, size - 1)); + const horizontal = ((x / Math.max(1, size - 1)) - 0.5) * dirX; + const sunlight = (activePhase?.key === 'night' ? 8 : activePhase?.key === 'evening' || activePhase?.key === 'morning' ? 12 : 16); + const depthBoost = depth * sunlight; + const exposure = (vertical * 0.7 + horizontal * 0.5) * (activePhase?.key === 'night' ? 9 : 13); + const amt = depthBoost + exposure; const r = clamp(rgb.r + amt, 0, 255); const g = clamp(rgb.g + amt, 0, 255); const b = clamp(rgb.b + amt, 0, 255); @@ -3795,6 +4425,49 @@ return `rgba(${r}, ${g}, ${b}, ${alpha})`; } + + function hexToRgbParts(hex) { + const clean = String(hex || '#000000').replace('#', ''); + const expanded = clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean.padEnd(6, '0').slice(0, 6); + const bigint = parseInt(expanded, 16); + return { r: (bigint >> 16) & 255, g: (bigint >> 8) & 255, b: bigint & 255 }; + } + + function rgbToHsl(r, g, b) { + r /= 255; g /= 255; b /= 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + let h, s; const l = (max + min) / 2; + if (max === min) { h = 0; s = 0; } + else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + default: h = (r - g) / d + 4; break; + } + h *= 60; + } + return { h, s: s * 100, l: l * 100 }; + } + + function varyHexColor(hex, hueRange = 12, satRange = 10, lightRange = 10) { + const rgb = parseHex(hex); + if (!rgb) return hex; + const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); + const h = mod(hsl.h + (Math.random() * 2 - 1) * hueRange, 360); + const s = clamp(hsl.s + (Math.random() * 2 - 1) * satRange, 14, 100); + const l = clamp(hsl.l + (Math.random() * 2 - 1) * lightRange, 10, 94); + return hslToHex(h, s, l); + } + + function mixHex(a, b, t) { + const ca = hexToRgbParts(a); + const cb = hexToRgbParts(b); + const toHex = (value) => Math.round(value).toString(16).padStart(2, '0'); + return `#${toHex(lerp(ca.r, cb.r, t))}${toHex(lerp(ca.g, cb.g, t))}${toHex(lerp(ca.b, cb.b, t))}`; + } + function toast(message) { clearTimeout(toastTimer); els.toast.textContent = message; @@ -3816,83 +4489,110 @@ } } - function drawHouse() { + function drawCottage() { const s = 16, p = blankPixels(s); - rect(p, s, 4, 8, 8, 6, '#c57955'); - rect(p, s, 6, 10, 3, 4, '#423044'); - rect(p, s, 10, 9, 2, 2, '#ffdd7d'); - rect(p, s, 4, 7, 8, 1, '#9b5163'); + rect(p, s, 4, 8, 8, 6, '#b86b4f'); + rect(p, s, 5, 9, 6, 4, '#d68b5e'); + rect(p, s, 6, 11, 3, 3, '#3b3348'); + rect(p, s, 10, 9, 2, 2, '#ffe38a'); + rect(p, s, 3, 7, 10, 1, '#6a485d'); for (let y = 3; y <= 7; y++) { - for (let x = 3 + Math.abs(6 - y); x <= 12 - Math.abs(6 - y); x++) px(p, s, x, y, '#81495c'); + const inset = Math.abs(6 - y); + for (let x = 3 + inset; x <= 12 - inset; x++) px(p, s, x, y, y < 5 ? '#8e4f61' : '#74465b'); } - rect(p, s, 2, 8, 12, 1, '#654154'); + px(p, s, 6, 8, '#ffcf75'); px(p, s, 11, 12, '#7b493f'); return p; } - function drawTree() { + function drawPineCluster() { const s = 16, p = blankPixels(s); - rect(p, s, 7, 9, 2, 5, '#805032'); - circle(p, s, 8, 5, 4, '#58b869'); - circle(p, s, 5, 7, 3, '#4aa95d'); - circle(p, s, 11, 7, 3, '#4aa95d'); - circle(p, s, 8, 8, 4, '#63ca75'); - px(p, s, 6, 5, '#8bef8f'); - px(p, s, 10, 4, '#8bef8f'); + rect(p, s, 6, 9, 2, 5, '#765238'); + rect(p, s, 10, 10, 2, 4, '#765238'); + for (let y = 2; y <= 10; y++) { + const w = Math.floor((y + 1) / 2); + for (let x = 7 - w; x <= 7 + w; x++) px(p, s, x, y, y % 2 ? '#348f5a' : '#42aa68'); + } + for (let y = 5; y <= 11; y++) { + const w = Math.floor((y - 2) / 2); + for (let x = 11 - w; x <= 11 + w; x++) px(p, s, x, y, y % 2 ? '#2f7f55' : '#3d9d62'); + } + px(p, s, 5, 4, '#8ee68c'); px(p, s, 10, 6, '#8ee68c'); return p; } - function drawLantern() { + function drawShellRock() { const s = 16, p = blankPixels(s); - rect(p, s, 7, 5, 2, 8, '#514b60'); - rect(p, s, 5, 4, 6, 1, '#514b60'); - rect(p, s, 6, 5, 4, 4, '#b8e8ff'); - rect(p, s, 3, 13, 10, 1, '#86623f'); - px(p, s, 5, 9, '#514b60'); px(p, s, 10, 9, '#514b60'); + rect(p, s, 5, 10, 7, 3, '#b9ad9f'); + rect(p, s, 4, 11, 9, 2, '#968b83'); + px(p, s, 6, 9, '#f0e0ca'); px(p, s, 8, 9, '#f0e0ca'); px(p, s, 10, 9, '#f0e0ca'); + rect(p, s, 7, 8, 4, 2, '#ffb6b1'); + px(p, s, 9, 7, '#ffd7cb'); px(p, s, 11, 10, '#6d625f'); return p; } - function drawHumanRight() { + function drawShip() { const s = 16, p = blankPixels(s); - rect(p, s, 7, 3, 3, 3, '#e6b887'); - rect(p, s, 6, 6, 5, 5, '#5d8bea'); - px(p, s, 10, 4, '#273046'); - rect(p, s, 5, 7, 1, 3, '#e6b887'); rect(p, s, 11, 7, 1, 3, '#e6b887'); - rect(p, s, 6, 11, 2, 3, '#31354f'); rect(p, s, 9, 11, 2, 3, '#31354f'); - rect(p, s, 6, 2, 5, 1, '#3a2b35'); + rect(p, s, 4, 10, 8, 2, '#7d513c'); + rect(p, s, 5, 12, 6, 1, '#50392f'); + px(p, s, 3, 9, '#7d513c'); px(p, s, 12, 9, '#7d513c'); + rect(p, s, 8, 4, 1, 6, '#5b4b48'); + for (let y = 4; y <= 8; y++) for (let x = 9; x <= 12 - Math.floor((y - 4) / 2); x++) px(p, s, x, y, '#f3ead8'); + for (let y = 5; y <= 8; y++) for (let x = 5 + Math.floor((y - 5) / 2); x <= 7; x++) px(p, s, x, y, '#ffd66e'); + px(p, s, 10, 5, '#ffffff'); px(p, s, 6, 6, '#fff7d1'); return p; } - function drawHumanLeft() { return mirrorPixels(drawHumanRight(), 16); } - function drawDogRight() { - const s = 16, p = blankPixels(s); - rect(p, s, 4, 8, 7, 4, '#b77a46'); - rect(p, s, 10, 7, 3, 3, '#c98952'); - px(p, s, 12, 8, '#2f1e17'); - rect(p, s, 5, 12, 1, 2, '#754b31'); rect(p, s, 9, 12, 1, 2, '#754b31'); - px(p, s, 3, 8, '#b77a46'); px(p, s, 2, 7, '#b77a46'); - return p; + function drawShipDepth() { + const s = 16, d = Array(s * s).fill(0); + for (let y = 10; y <= 12; y++) for (let x = 4; x <= 12; x++) d[y * s + x] = -1; + for (let y = 4; y <= 8; y++) d[y * s + 8] = 1; + return d; } - function drawDogLeft() { return mirrorPixels(drawDogRight(), 16); } - function drawFishRight() { + function drawFisherKidRight() { const s = 16, p = blankPixels(s); - rect(p, s, 5, 7, 6, 3, '#4bd7ff'); - px(p, s, 11, 8, '#e7fbff'); px(p, s, 4, 7, '#258ac0'); px(p, s, 3, 6, '#258ac0'); px(p, s, 3, 10, '#258ac0'); - px(p, s, 6, 6, '#90edff'); px(p, s, 8, 10, '#90edff'); + rect(p, s, 7, 3, 3, 3, '#d9a06c'); + rect(p, s, 6, 6, 5, 4, '#e2b94f'); + rect(p, s, 6, 10, 2, 4, '#31577e'); + rect(p, s, 9, 10, 2, 4, '#31577e'); + rect(p, s, 6, 2, 5, 1, '#35434a'); + px(p, s, 10, 4, '#202631'); + px(p, s, 12, 7, '#5b4b48'); px(p, s, 13, 8, '#5b4b48'); return p; } - function drawFishLeft() { return mirrorPixels(drawFishRight(), 16); } - function drawBirdRight() { + function drawCatRight() { const s = 16, p = blankPixels(s); - rect(p, s, 7, 6, 3, 3, '#f4d45f'); - px(p, s, 10, 7, '#f09062'); - rect(p, s, 4, 6, 3, 1, '#6da9ef'); - rect(p, s, 10, 5, 3, 1, '#6da9ef'); - px(p, s, 8, 5, '#242434'); + rect(p, s, 4, 8, 7, 4, '#5c8a57'); + rect(p, s, 10, 7, 3, 3, '#6fa064'); + px(p, s, 10, 6, '#6fa064'); px(p, s, 12, 6, '#6fa064'); + px(p, s, 12, 8, '#1e2430'); px(p, s, 13, 9, '#d9a06c'); + rect(p, s, 5, 12, 1, 2, '#3e633e'); rect(p, s, 9, 12, 1, 2, '#3e633e'); + px(p, s, 3, 8, '#5c8a57'); px(p, s, 2, 7, '#5c8a57'); return p; } - function drawBirdLeft() { return mirrorPixels(drawBirdRight(), 16); } + + function drawKoiRight() { + const s = 16, p = blankPixels(s); + rect(p, s, 5, 7, 6, 3, '#f7f2e8'); + px(p, s, 6, 7, '#ff7d5a'); px(p, s, 8, 8, '#ff7d5a'); px(p, s, 10, 9, '#ff7d5a'); + px(p, s, 11, 8, '#1d2530'); + px(p, s, 4, 7, '#ffb165'); px(p, s, 3, 6, '#ffb165'); px(p, s, 3, 10, '#ffb165'); + px(p, s, 7, 6, '#fffdf7'); px(p, s, 9, 10, '#fffdf7'); + return p; + } + + function drawGullRight() { + const s = 16, p = blankPixels(s); + rect(p, s, 7, 6, 3, 3, '#fffdf7'); + rect(p, s, 4, 6, 3, 1, '#dfe6ee'); + rect(p, s, 10, 5, 4, 1, '#dfe6ee'); + px(p, s, 10, 7, '#f0a45c'); + px(p, s, 8, 5, '#1e2430'); + px(p, s, 6, 8, '#c9d2dc'); px(p, s, 9, 8, '#c9d2dc'); + return p; + } + bootstrap(); })(); diff --git a/index.html b/index.html index d3fe0a3..0360c91 100644 --- a/index.html +++ b/index.html @@ -71,6 +71,7 @@ + @@ -155,7 +156,7 @@

Click an asset to select it and jump to it on the map. Copy Edit creates a new derivative.

@@ -177,6 +178,28 @@
+ + +
+
Visual settings
+

Turn major visual systems on or off.

+
+ + + +
+
+ +
+
Phase 5 guardrails
+

Local pre-server checks for object volume, orphaned placements, terrain compatibility, hidden objects, and report logs.

+
+
+ + + +
+
@@ -193,10 +216,31 @@
+
+ + diff --git a/js/phase2-sync.js b/js/phase2-sync.js index 3798bf4..20692a6 100644 --- a/js/phase2-sync.js +++ b/js/phase2-sync.js @@ -203,6 +203,9 @@ assetVotes: state.assetVotes || {}, hiddenAssets: state.hiddenAssets || {}, hiddenObjects: state.hiddenObjects || {}, + moderationReports: Array.isArray(state.moderationReports) ? state.moderationReports : [], + guardrails: state.guardrails || null, + settings: state.settings || null, eventLog: Array.isArray(state.eventLog) ? state.eventLog.slice(-EVENT_LOG_LIMIT) : [], sync: state.sync || { lastEventId: null } }; @@ -220,6 +223,9 @@ assetVotes: input.assetVotes || {}, hiddenAssets: input.hiddenAssets || {}, hiddenObjects: input.hiddenObjects || {}, + moderationReports: Array.isArray(input.moderationReports) ? input.moderationReports : [], + guardrails: input.guardrails || null, + settings: input.settings || null, eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-EVENT_LOG_LIMIT) : [], sync: input.sync || { lastEventId: null } }; diff --git a/styles.css b/styles.css index c673c22..f0c5455 100644 --- a/styles.css +++ b/styles.css @@ -899,3 +899,72 @@ body, button, input, select, textarea { font-size: 12px; line-height: 1.45; } + +/* Phase 5: keep advanced depth controls inside the drawer. */ +.advancedRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + max-width: 100%; + overflow: hidden; +} +.advancedRow .tool { + flex: 0 1 auto; + min-width: 0; + max-width: 100%; + padding: 7px 7px; + white-space: nowrap; +} +#advancedHint { + flex: 1 0 100%; + min-width: 0; +} +#guardrailStats { + white-space: pre-wrap; +} +.bubbleActions { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.bubbleActions button:only-child, +.bubbleActions button:nth-child(3):last-child { + grid-column: span 2; +} + +/* Phase 5 follow-up: object hidden list + report dialog + font priority. */ +body, button, input, select, textarea { + font-family: "3x5 MT Pixel Font", "Press Start 2P", "PixelMplus10", "DotGothic16", "MS Gothic", "Osaka-Mono", "Courier New", ui-monospace, monospace; +} +.hiddenSectionTitle { + margin: 8px 0 6px; + padding: 5px 7px; + background: #ffe6f0; + border: 2px solid var(--line); + font-size: 11px; + font-weight: 900; + text-transform: uppercase; +} +.reportDialog { + position: absolute; + z-index: 20; + inset: 0; + display: grid; + place-items: center; + background: rgba(20, 25, 34, .34); +} +.reportDialog[hidden] { display: none; } +.reportCard { + width: min(360px, calc(100vw - 28px)); + background: #fffdf5; + border: 3px solid var(--line); + box-shadow: 8px 8px 0 rgba(36,48,68,.25); + padding: 14px; +} +.reportCard .hint { margin: 8px 0 10px; } +.reportActions button { min-width: 0; } +.assetActions button { min-width: 0; } + + +.toggleList { display:flex; flex-direction:column; gap:10px; } +.checkRow { display:flex; align-items:center; gap:10px; font-size:13px; color:#243044; } +.checkRow input { width:16px; height:16px; }