2026-06-01 18:27:25 +09:00
( ( ) => {
'use strict' ;
console . info ( 'Pixel Island Summoner loaded' ) ;
2026-06-02 16:26:44 +09:00
const STORAGE _KEY = 'pixel-island-summoner:phase5e' ;
const SAVE _SCHEMA = 13 ;
2026-06-01 18:27:25 +09:00
const WORLD _W = 144 ;
const WORLD _H = 112 ;
const TILE _W = 32 ;
const TILE _H = 16 ;
const ORIGIN _X = WORLD _H * TILE _W / 2 + 80 ;
const ORIGIN _Y = 42 ;
const DAY _MS = 10 * 60 * 1000 ;
const STATIC _SCALE = 2 ;
const DYNAMIC _SCALE = 1 ;
const MAX _ZOOM = 3.2 ;
const MIN _ZOOM = 0.45 ;
2026-06-01 22:48:15 +09:00
const TERRAIN _CHUNK _SIZE = 16 ;
const VIEW _CULL _MARGIN = 192 ;
const DYNAMIC _LOGIC _STEP _MS = 1000 / 15 ;
const MAX _DYNAMIC _STEPS _PER _FRAME = 3 ;
2026-06-01 18:27:25 +09:00
const COLOR _CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' ;
const PALETTE = buildPalette ( ) ;
const PALETTE _BY _CODE = Object . fromEntries ( PALETTE . map ( ( p ) => [ p . code , p . color ] ) ) ;
2026-06-01 22:48:15 +09:00
const MODULES = window . PixelIslandModules || { } ;
const EditorActions = MODULES . EditorActions || null ;
const Phase2Sync = MODULES . Phase2Sync || null ;
const EDITOR _HISTORY _LIMIT = 80 ;
2026-06-02 02:35:22 +09:00
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 } ;
2026-06-01 18:27:25 +09:00
const $ = ( id ) => document . getElementById ( id ) ;
const clamp = ( value , min , max ) => Math . max ( min , Math . min ( max , value ) ) ;
const uid = ( ) => Math . random ( ) . toString ( 36 ) . slice ( 2 , 9 ) + Date . now ( ) . toString ( 36 ) . slice ( - 5 ) ;
const mod = ( n , m ) => ( ( n % m ) + m ) % m ;
const lerp = ( a , b , t ) => a + ( b - a ) * t ;
2026-06-02 00:05:03 +09:00
function defaultVisualSettings ( ) {
2026-06-02 02:35:22 +09:00
return { enableLights : true , enableParticles : true , enableDayNight : true , localDisplayLimit : PHASE5 _GUARDRAILS . defaultDisplayLimit } ;
2026-06-02 00:05:03 +09:00
}
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 ;
2026-06-02 02:35:22 +09:00
if ( els . displayLimit ) els . displayLimit . value = String ( getLocalDisplayLimit ( ) ) ;
2026-06-02 00:05:03 +09:00
}
function clearVisualEffectState ( ) {
spawnEffects = [ ] ;
bubbleParticles = [ ] ;
confettiParticles = [ ] ;
landStepParticles = [ ] ;
natureDriftParticles = [ ] ;
}
2026-06-01 18:27:25 +09:00
function toggleHidden ( el , hidden ) {
if ( el ) el . hidden = hidden ;
}
function currentRole ( ) {
return els . assetCategory ? . value || 'nature' ;
}
function roleToCategory ( role ) {
return role === 'human' || role === 'animal' ? 'dynamic' : 'static' ;
}
function roleToSubtype ( role ) {
return role || 'other' ;
}
function subtypeToRole ( asset ) {
const subtype = asset ? . subtype ;
2026-06-02 00:05:03 +09:00
if ( [ 'human' , 'animal' , 'nature' , 'building' , 'ship' , 'other' ] . includes ( subtype ) ) return subtype ;
2026-06-01 18:27:25 +09:00
if ( subtype === 'water' ) return 'other' ;
if ( asset ? . category === 'dynamic' ) return 'animal' ;
return 'other' ;
}
const els = {
canvas : $ ( 'worldCanvas' ) ,
openEditor : $ ( 'openEditor' ) ,
2026-06-02 16:26:44 +09:00
openCreate : $ ( 'openCreate' ) ,
openCollection : $ ( 'openCollection' ) ,
openMenu : $ ( 'openMenu' ) ,
2026-06-01 18:27:25 +09:00
closeEditor : $ ( 'closeEditor' ) ,
2026-06-02 16:26:44 +09:00
placementPreviewBar : $ ( 'placementPreviewBar' ) , confirmPreviewPlace : $ ( 'confirmPreviewPlace' ) , backToCanvas : $ ( 'backToCanvas' ) , createAccount : $ ( 'createAccount' ) , accountNote : $ ( 'accountNote' ) , accountId : $ ( 'accountId' ) , accountPass : $ ( 'accountPass' ) ,
2026-06-01 18:27:25 +09:00
drawer : $ ( 'studioDrawer' ) ,
tabs : [ ... document . querySelectorAll ( '.tab' ) ] ,
panels : [ ... document . querySelectorAll ( '.tabPanel' ) ] ,
phaseLabel : $ ( 'phaseLabel' ) ,
phaseBar : $ ( 'phaseBar' ) ,
2026-06-02 16:26:44 +09:00
analogClock : $ ( 'analogClock' ) ,
analogClockHand : $ ( 'analogClockHand' ) ,
2026-06-01 18:27:25 +09:00
authorName : $ ( 'authorName' ) ,
selectedAssetName : $ ( 'selectedAssetName' ) ,
tileInfo : $ ( 'tileInfo' ) ,
toast : $ ( 'toast' ) ,
2026-06-02 16:26:44 +09:00
selectionBubble : $ ( 'selectionBubble' ) , bubbleName : $ ( 'bubbleName' ) , bubbleAuthor : $ ( 'bubbleAuthor' ) , bubbleRemixFrom : $ ( 'bubbleRemixFrom' ) , bubbleRemixCount : $ ( 'bubbleRemixCount' ) , voteScore : $ ( 'voteScore' ) , voteUp : $ ( 'voteUp' ) , voteDown : $ ( 'voteDown' ) , bubbleRemix : $ ( 'bubbleRemix' ) , bubbleTeleport : $ ( 'bubbleTeleport' ) , bubbleMenu : $ ( 'bubbleMenu' ) , bubbleMenuActions : $ ( 'bubbleMenuActions' ) , bubbleEdit : $ ( 'bubbleEdit' ) , bubbleReport : $ ( 'bubbleReport' ) , bubbleHide : $ ( 'bubbleHide' ) ,
2026-06-01 18:27:25 +09:00
modeInspect : $ ( 'modeInspect' ) , modePlace : $ ( 'modePlace' ) , modeErase : $ ( 'modeErase' ) ,
drawerInspect : $ ( 'drawerInspect' ) , drawerPlace : $ ( 'drawerPlace' ) , drawerErase : $ ( 'drawerErase' ) ,
zoomOut : $ ( 'zoomOut' ) , zoomIn : $ ( 'zoomIn' ) , resetView : $ ( 'resetView' ) ,
drawerZoomOut : $ ( 'drawerZoomOut' ) , drawerZoomIn : $ ( 'drawerZoomIn' ) , drawerResetView : $ ( 'drawerResetView' ) ,
assetName : $ ( 'assetName' ) ,
assetSize : $ ( 'assetSize' ) ,
assetCategory : $ ( 'assetCategory' ) ,
staticKindWrap : $ ( 'staticKindWrap' ) ,
dynamicKindWrap : $ ( 'dynamicKindWrap' ) ,
roleHint : $ ( 'roleHint' ) ,
sideSwitcher : $ ( 'sideSwitcher' ) ,
editRight : $ ( 'editRight' ) , editLeft : $ ( 'editLeft' ) ,
2026-06-01 22:48:15 +09:00
paintColor : $ ( 'paintColor' ) , toolBrush : $ ( 'toolBrush' ) , toolErase : $ ( 'toolErase' ) , toolFill : $ ( 'toolFill' ) , toolPick : $ ( 'toolPick' ) , toolLine : $ ( 'toolLine' ) , toolRect : $ ( 'toolRect' ) , toolSelect : $ ( 'toolSelect' ) , undoPaint : $ ( 'undoPaint' ) , redoPaint : $ ( 'redoPaint' ) ,
2026-06-02 16:26:44 +09:00
toolLight : $ ( 'toolLight' ) , toolParticle : $ ( 'toolParticle' ) , toolDoor : $ ( 'toolDoor' ) , toolDepth : $ ( 'toolDepth' ) , depthHigh : $ ( 'depthHigh' ) , depthLow : $ ( 'depthLow' ) , toggleAdvanced : $ ( 'toggleAdvanced' ) , advancedHint : $ ( 'advancedHint' ) , advancedToolGroup : $ ( 'advancedToolGroup' ) , particleDirectionWrap : $ ( 'particleDirectionWrap' ) , particleDirection : $ ( 'particleDirection' ) , particleUseSelection : $ ( 'particleUseSelection' ) , particleClearRange : $ ( 'particleClearRange' ) , particleRangeStatus : $ ( 'particleRangeStatus' ) , clearPaint : $ ( 'clearPaint' ) , flipHorizontal : $ ( 'flipHorizontal' ) , flipVertical : $ ( 'flipVertical' ) , outlinePaint : $ ( 'outlinePaint' ) , clearSelection : $ ( 'clearSelection' ) , nudgeLeft : $ ( 'nudgeLeft' ) , nudgeRight : $ ( 'nudgeRight' ) , nudgeUp : $ ( 'nudgeUp' ) , nudgeDown : $ ( 'nudgeDown' ) , exportPng : $ ( 'exportPng' ) , importPng : $ ( 'importPng' ) , pngImportInput : $ ( 'pngImportInput' ) ,
2026-06-01 18:27:25 +09:00
paintCanvas : $ ( 'paintCanvas' ) , editHint : $ ( 'editHint' ) , paletteGrid : $ ( 'paletteGrid' ) ,
lightColor : $ ( 'lightColor' ) , staticSettingsPanel : $ ( 'staticSettingsPanel' ) , dynamicSettingsPanel : $ ( 'dynamicSettingsPanel' ) , doorMarkerHint : $ ( 'doorMarkerHint' ) ,
2026-06-02 02:35:22 +09:00
settingsSummary : $ ( 'settingsSummary' ) , saveAsset : $ ( 'saveAsset' ) , saveAndPlace : $ ( 'saveAndPlace' ) , checkOnIsland : $ ( 'checkOnIsland' ) , newAsset : $ ( 'newAsset' ) ,
lineageNote : $ ( 'lineageNote' ) , assetList : $ ( 'assetList' ) , likedCodex : $ ( 'likedCodex' ) , showHiddenAssets : $ ( 'showHiddenAssets' ) , hiddenAssetPanel : $ ( 'hiddenAssetPanel' ) , hiddenAssetList : $ ( 'hiddenAssetList' ) ,
2026-06-01 22:48:15 +09:00
exportData : $ ( 'exportData' ) , importData : $ ( 'importData' ) , resetAll : $ ( 'resetAll' ) , dataBox : $ ( 'dataBox' ) ,
2026-06-02 00:05:03 +09:00
exportCompact : $ ( 'exportCompact' ) , exportSnapshot : $ ( 'exportSnapshot' ) , exportAssetBundle : $ ( 'exportAssetBundle' ) , syncStats : $ ( 'syncStats' ) , guardrailStats : $ ( 'guardrailStats' ) , validateWorld : $ ( 'validateWorld' ) , exportModerationReport : $ ( 'exportModerationReport' ) , clearReports : $ ( 'clearReports' ) ,
2026-06-02 02:35:22 +09:00
settingLights : $ ( 'settingLights' ) , settingParticles : $ ( 'settingParticles' ) , settingDayNight : $ ( 'settingDayNight' ) , displayLimit : $ ( 'displayLimit' ) , rotationStats : $ ( 'rotationStats' ) ,
2026-06-02 00:05:03 +09:00
reportDialog : $ ( 'reportDialog' ) , reportReason : $ ( 'reportReason' ) , reportCancel : $ ( 'reportCancel' ) , reportSubmit : $ ( 'reportSubmit' ) , reportObjectName : $ ( 'reportObjectName' )
2026-06-01 18:27:25 +09:00
} ;
const ctx = els . canvas . getContext ( '2d' , { alpha : false } ) ;
const pctx = els . paintCanvas . getContext ( '2d' , { alpha : true } ) ;
ctx . imageSmoothingEnabled = false ;
pctx . imageSmoothingEnabled = false ;
let dpr = window . devicePixelRatio || 1 ;
let cw = 1 ;
let ch = 1 ;
let world = makeWorld ( ) ;
let terrainCache = buildTerrainCache ( world ) ;
let state = loadState ( ) ;
let selectedAssetId = state . assets [ 0 ] ? . id ? ? null ;
let mode = 'inspect' ;
let view = { x : 0 , y : 0 , zoom : 1 } ;
let pointer = {
down : false , id : null , startX : 0 , startY : 0 , lastX : 0 , lastY : 0 ,
2026-06-01 22:48:15 +09:00
dragging : false , downTime : 0 , button : 0
2026-06-01 18:27:25 +09:00
} ;
let hoverTile = null ;
let dynamicRuntime = [ ] ;
let spriteCache = new Map ( ) ;
let lastRuntimeUpdate = performance . now ( ) ;
let lastClockSecond = - 1 ;
let toastTimer = null ;
let editorSize = 8 ;
let editorPixels = blankPixels ( 8 ) ;
let editorLeftPixels = blankPixels ( 8 ) ;
let editingSide = 'right' ;
let paintTool = 'brush' ;
let selectedColorCode = 'a' ;
2026-06-01 21:17:09 +09:00
let advancedDraw = false ;
let depthPixels = blankPixels ( 8 ) . map ( ( ) => 0 ) ;
2026-06-01 22:48:15 +09:00
let depthPaintMode = 1 ;
2026-06-01 18:27:25 +09:00
let isPainting = false ;
let staticKind = 'nature' ;
let dynamicKind = 'human' ;
let lightPixels = [ ] ;
2026-06-02 02:35:22 +09:00
let particlePixels = [ ] ;
2026-06-02 16:26:44 +09:00
let particleConfig = { enabled : false , c : 'f' , dir : 'up' } ;
2026-06-01 18:27:25 +09:00
let doorPixel = { x : 8 , y : 15 } ;
let editParentId = null ;
let editOriginalId = null ;
let editingAssetId = null ;
let lastPaintedKey = '' ;
let spawnEffects = [ ] ;
let bubbleParticles = [ ] ;
let confettiParticles = [ ] ;
2026-06-02 00:05:03 +09:00
let landStepParticles = [ ] ;
let natureDriftParticles = [ ] ;
let coastalFoamTextures = null ;
2026-06-01 18:27:25 +09:00
let mousePaint = { active : false , panning : false , lastX : 0 , lastY : 0 , button : 0 } ;
let shadowCanvasCache = new WeakMap ( ) ;
2026-06-01 22:48:15 +09:00
let animationFrameId = 0 ;
let dynamicLogicRemainder = 0 ;
let worldIndex = makeEmptyWorldIndex ( ) ;
2026-06-01 18:27:25 +09:00
let selectedObject = null ;
2026-06-02 00:05:03 +09:00
let pendingReport = null ;
2026-06-01 21:17:09 +09:00
let libraryFilter = 'all' ;
2026-06-01 18:27:25 +09:00
let renderPhase = null ;
let editorView = { zoom : 1 , x : 0 , y : 0 } ;
let editorPointer = { panning : false , pointerId : null , lastX : 0 , lastY : 0 } ;
2026-06-01 22:48:15 +09:00
let editorHistory = [ ] ;
let editorFuture = [ ] ;
let editorGestureSnapshot = null ;
let editorGestureChanged = false ;
let suppressNextPaintClick = false ;
let suppressMousePaintUntil = 0 ;
let editorSelection = null ;
let selectionGesture = null ;
let shapeGesture = null ;
let shapePreview = null ;
2026-06-02 02:35:22 +09:00
let placementPreview = null ;
2026-06-01 18:27:25 +09:00
function bootstrap ( ) {
resizeCanvas ( ) ;
resetView ( false ) ;
hydrateRuntime ( ) ;
2026-06-02 00:05:03 +09:00
coastalFoamTextures = buildCoastalFoamTextures ( ) ;
2026-06-01 18:27:25 +09:00
wireUI ( ) ;
renderPalette ( ) ;
hydrateAuthorUI ( ) ;
2026-06-02 02:35:22 +09:00
updateAccountUI ( ) ;
2026-06-01 18:27:25 +09:00
refreshCategoryUI ( ) ;
2026-06-01 22:48:15 +09:00
setupEditor ( 8 , blankPixels ( 8 ) , null ) ;
clearEditorHistory ( ) ;
2026-06-01 18:27:25 +09:00
renderLibrary ( ) ;
updateSelectedLabel ( ) ;
2026-06-01 22:48:15 +09:00
rebuildWorldIndex ( ) ;
updateSyncStats ( ) ;
2026-06-02 00:05:03 +09:00
updateGuardrailStats ( ) ;
hydrateVisualSettingsUI ( ) ;
2026-06-02 02:35:22 +09:00
updateRotationStats ( ) ;
2026-06-01 22:48:15 +09:00
scheduleFrame ( ) ;
2026-06-01 18:27:25 +09:00
}
function wireUI ( ) {
window . addEventListener ( 'resize' , ( ) => {
resizeCanvas ( ) ;
render ( ) ;
} ) ;
2026-06-02 16:26:44 +09:00
els . openEditor ? . addEventListener ( 'click' , ( ) => { clearWorldSelection ( false ) ; setDrawerOpen ( true ) ; } ) ;
els . openCreate ? . addEventListener ( 'click' , ( ) => { setTab ( 'draw' ) ; clearWorldSelection ( false ) ; setDrawerOpen ( true ) ; } ) ;
els . openCollection ? . addEventListener ( 'click' , ( ) => { setTab ( 'library' ) ; clearWorldSelection ( false ) ; setDrawerOpen ( true ) ; } ) ;
2026-06-01 18:27:25 +09:00
els . closeEditor . addEventListener ( 'click' , ( ) => setDrawerOpen ( false ) ) ;
els . tabs . forEach ( ( button ) => {
button . addEventListener ( 'click' , ( ) => setTab ( button . dataset . tab ) ) ;
} ) ;
els . authorName ? . addEventListener ( 'input' , ( ) => {
2026-06-02 16:26:44 +09:00
const fallback = state . account ? . id || 'Local Artist' ;
state . authorName = ( els . authorName . value || fallback ) . trim ( ) || fallback ;
2026-06-02 02:35:22 +09:00
if ( state . account ) state . account . name = state . authorName ;
2026-06-01 21:17:09 +09:00
saveState ( ) ;
2026-06-02 02:35:22 +09:00
updateAccountUI ( ) ;
2026-06-01 18:27:25 +09:00
renderLibrary ( ) ;
} ) ;
2026-06-02 16:26:44 +09:00
els . accountPass ? . addEventListener ( 'input' , ( ) => {
if ( ! state . account ) return ;
state . account . password = ( els . accountPass . value || '' ) . trim ( ) ;
saveState ( ) ;
updateAccountUI ( ) ;
} ) ;
els . createAccount ? . addEventListener ( 'click' , ( ) => createLocalAccount ( false ) ) ;
2026-06-02 02:35:22 +09:00
els . confirmPreviewPlace ? . addEventListener ( 'click' , confirmPreviewPlacement ) ;
els . backToCanvas ? . addEventListener ( 'click' , cancelPlacementPreview ) ;
2026-06-02 00:05:03 +09:00
els . settingLights ? . addEventListener ( 'change' , ( ) => {
visualSettings ( ) . enableLights = ! ! els . settingLights . checked ;
2026-06-02 16:26:44 +09:00
spriteCache . clear ( ) ;
2026-06-02 00:05:03 +09:00
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 ( ) ;
} ) ;
2026-06-02 02:35:22 +09:00
els . displayLimit ? . addEventListener ( 'change' , ( ) => {
const value = clampInt ( els . displayLimit . value , 25 , 500 , PHASE5 _GUARDRAILS . defaultDisplayLimit ) ;
visualSettings ( ) . localDisplayLimit = value ;
els . displayLimit . value = String ( value ) ;
saveState ( ) ;
renderLibrary ( ) ;
render ( ) ;
} ) ;
2026-06-01 18:27:25 +09:00
[ els . modeInspect , els . drawerInspect ] . filter ( Boolean ) . forEach ( ( el ) => el . addEventListener ( 'click' , ( ) => setMode ( 'inspect' ) ) ) ;
[ els . modePlace , els . drawerPlace ] . filter ( Boolean ) . forEach ( ( el ) => el . addEventListener ( 'click' , ( ) => setMode ( 'place' ) ) ) ;
[ els . modeErase , els . drawerErase ] . filter ( Boolean ) . forEach ( ( el ) => el . addEventListener ( 'click' , ( ) => setMode ( 'erase' ) ) ) ;
[ els . zoomOut , els . drawerZoomOut ] . filter ( Boolean ) . forEach ( ( el ) => el . addEventListener ( 'click' , ( ) => zoomAt ( cw / 2 , ch / 2 , view . zoom / 1.2 ) ) ) ;
[ els . zoomIn , els . drawerZoomIn ] . filter ( Boolean ) . forEach ( ( el ) => el . addEventListener ( 'click' , ( ) => zoomAt ( cw / 2 , ch / 2 , view . zoom * 1.2 ) ) ) ;
[ els . resetView , els . drawerResetView ] . filter ( Boolean ) . forEach ( ( el ) => el . addEventListener ( 'click' , ( ) => resetView ( true ) ) ) ;
els . canvas . addEventListener ( 'pointerdown' , onWorldPointerDown ) ;
els . canvas . addEventListener ( 'pointermove' , onWorldPointerMove ) ;
els . canvas . addEventListener ( 'pointerup' , onWorldPointerUp ) ;
els . canvas . addEventListener ( 'pointercancel' , onWorldPointerUp ) ;
els . canvas . addEventListener ( 'wheel' , onWorldWheel , { passive : false } ) ;
2026-06-02 16:26:44 +09:00
els . canvas . addEventListener ( 'contextmenu' , ( event ) => {
event . preventDefault ( ) ;
cancelPlacementPreview ( false ) ;
clearWorldSelection ( true ) ;
setMode ( 'inspect' ) ;
} ) ;
2026-06-01 18:27:25 +09:00
document . addEventListener ( 'contextmenu' , onDocumentContextMenu ) ;
2026-06-01 22:48:15 +09:00
document . addEventListener ( 'visibilitychange' , onVisibilityChange ) ;
2026-06-01 18:27:25 +09:00
els . assetSize . addEventListener ( 'change' , ( ) => {
2026-06-01 22:48:15 +09:00
const oldSize = editorSize ;
2026-06-01 18:27:25 +09:00
const nextSize = Number ( els . assetSize . value ) ;
2026-06-01 22:48:15 +09:00
commitEditorMutation ( ( ) => {
const resizedRight = resizePixels ( editorPixels , oldSize , nextSize ) ;
const resizedDepth = resizeDepthPixels ( depthPixels , oldSize , nextSize ) ;
setupEditor ( nextSize , resizedRight , null , resizedDepth ) ;
doorPixel = { x : Math . min ( doorPixel . x , nextSize - 1 ) , y : Math . min ( doorPixel . y , nextSize - 1 ) } ;
lightPixels = lightPixels . filter ( ( p ) => p . x < nextSize && p . y < nextSize ) ;
2026-06-02 02:35:22 +09:00
particlePixels = particlePixels . filter ( ( p ) => p . x < nextSize && p . y < nextSize ) ;
2026-06-01 22:48:15 +09:00
resetEditorView ( ) ;
return true ;
} ) ;
2026-06-01 18:27:25 +09:00
updateSettingsSummary ( ) ;
} ) ;
els . assetCategory . addEventListener ( 'change' , refreshCategoryUI ) ;
document . querySelectorAll ( '[data-static-kind]' ) . forEach ( ( button ) => {
button . addEventListener ( 'click' , ( ) => {
staticKind = button . dataset . staticKind ;
document . querySelectorAll ( '[data-static-kind]' ) . forEach ( ( b ) => b . classList . toggle ( 'active' , b === button ) ) ;
refreshCategoryUI ( ) ;
} ) ;
} ) ;
document . querySelectorAll ( '[data-dynamic-kind]' ) . forEach ( ( button ) => {
button . addEventListener ( 'click' , ( ) => {
dynamicKind = button . dataset . dynamicKind ;
document . querySelectorAll ( '[data-dynamic-kind]' ) . forEach ( ( b ) => b . classList . toggle ( 'active' , b === button ) ) ;
refreshCategoryUI ( ) ;
} ) ;
} ) ;
els . editRight . addEventListener ( 'click' , ( ) => setEditingSide ( 'right' ) ) ;
els . editLeft . addEventListener ( 'click' , ( ) => setEditingSide ( 'left' ) ) ;
els . toolBrush . addEventListener ( 'click' , ( ) => setPaintTool ( 'brush' ) ) ;
els . toolErase . addEventListener ( 'click' , ( ) => setPaintTool ( 'erase' ) ) ;
2026-06-01 22:48:15 +09:00
els . toolFill ? . addEventListener ( 'click' , ( ) => setPaintTool ( 'fill' ) ) ;
els . toolPick ? . addEventListener ( 'click' , ( ) => setPaintTool ( 'pick' ) ) ;
els . toolLine ? . addEventListener ( 'click' , ( ) => setPaintTool ( 'line' ) ) ;
els . toolRect ? . addEventListener ( 'click' , ( ) => setPaintTool ( 'rect' ) ) ;
els . toolSelect ? . addEventListener ( 'click' , ( ) => setPaintTool ( 'select' ) ) ;
2026-06-01 18:27:25 +09:00
els . toolLight . addEventListener ( 'click' , ( ) => setPaintTool ( 'light' ) ) ;
els . toolDoor . addEventListener ( 'click' , ( ) => setPaintTool ( 'door' ) ) ;
2026-06-01 21:17:09 +09:00
els . toolDepth ? . addEventListener ( 'click' , ( ) => setPaintTool ( 'depth' ) ) ;
2026-06-02 16:26:44 +09:00
els . toolParticle ? . addEventListener ( 'click' , ( ) => { setPaintTool ( 'particle' ) ; enableParticleEffect ( ) ; } ) ;
els . particleDirection ? . addEventListener ( 'change' , ( ) => {
const before = JSON . stringify ( particleConfig ) ;
particleConfig = normalizeParticleConfig ( { ... particleConfig , dir : els . particleDirection . value || 'up' } ) ;
if ( particleConfig . enabled && JSON . stringify ( particleConfig ) !== before ) markEditorChanged ( ) ;
updateParticleUI ( ) ;
drawEditor ( ) ;
} ) ;
els . particleUseSelection ? . addEventListener ( 'click' , setParticleRangeFromSelection ) ;
els . particleClearRange ? . addEventListener ( 'click' , clearParticleRange ) ;
2026-06-01 22:48:15 +09:00
els . depthHigh ? . addEventListener ( 'click' , ( ) => setDepthPaintMode ( 1 ) ) ;
els . depthLow ? . addEventListener ( 'click' , ( ) => setDepthPaintMode ( - 1 ) ) ;
2026-06-01 21:17:09 +09:00
els . toggleAdvanced ? . addEventListener ( 'click' , ( ) => {
advancedDraw = ! advancedDraw ;
2026-06-02 16:26:44 +09:00
toggleHidden ( els . advancedToolGroup , ! advancedDraw ) ;
2026-06-01 21:17:09 +09:00
toggleHidden ( els . toolLight , ! advancedDraw ) ;
2026-06-02 02:35:22 +09:00
toggleHidden ( els . toolParticle , ! advancedDraw ) ;
2026-06-01 21:17:09 +09:00
toggleHidden ( els . toolDepth , ! advancedDraw ) ;
2026-06-01 22:48:15 +09:00
toggleHidden ( els . depthHigh , ! advancedDraw ) ;
toggleHidden ( els . depthLow , ! advancedDraw ) ;
2026-06-02 16:26:44 +09:00
toggleHidden ( els . particleDirectionWrap , ! advancedDraw ) ;
2026-06-01 21:17:09 +09:00
toggleHidden ( els . advancedHint , ! advancedDraw ) ;
els . toggleAdvanced . classList . toggle ( 'active' , advancedDraw ) ;
2026-06-02 16:26:44 +09:00
els . drawer ? . classList . toggle ( 'advancedTools' , advancedDraw ) ;
2026-06-02 02:35:22 +09:00
if ( ! advancedDraw && ( paintTool === 'depth' || paintTool === 'light' || paintTool === 'particle' ) ) setPaintTool ( 'brush' ) ;
2026-06-02 16:26:44 +09:00
updateParticleUI ( ) ;
2026-06-01 21:17:09 +09:00
drawEditor ( ) ;
2026-06-01 18:27:25 +09:00
} ) ;
2026-06-01 21:17:09 +09:00
els . clearPaint . addEventListener ( 'click' , ( ) => {
2026-06-02 16:26:44 +09:00
if ( ! confirm ( 'Clear the entire canvas? This removes all pixels, lights, depth, particles, and selection.' ) ) return ;
2026-06-01 22:48:15 +09:00
commitEditorMutation ( ( ) => {
editorPixels = blankPixels ( editorSize ) ;
editorLeftPixels = mirrorPixels ( editorPixels , editorSize ) ;
depthPixels = blankPixels ( editorSize ) . map ( ( ) => 0 ) ;
lightPixels = [ ] ;
2026-06-02 02:35:22 +09:00
particlePixels = [ ] ;
2026-06-02 16:26:44 +09:00
particleConfig = { enabled : false , c : selectedColorCode , dir : 'up' } ;
2026-06-01 22:48:15 +09:00
editorSelection = null ;
return true ;
} ) ;
2026-06-01 18:27:25 +09:00
} ) ;
2026-06-01 22:48:15 +09:00
els . flipHorizontal ? . addEventListener ( 'click' , flipEditorHorizontal ) ;
els . flipVertical ? . addEventListener ( 'click' , flipEditorVertical ) ;
els . outlinePaint ? . addEventListener ( 'click' , applyEditorOutline ) ;
2026-06-02 16:26:44 +09:00
els . clearSelection ? . addEventListener ( 'click' , ( ) => clearEditorSelection ( true ) ) ;
2026-06-01 22:48:15 +09:00
els . nudgeLeft ? . addEventListener ( 'click' , ( ) => nudgeEditor ( - 1 , 0 ) ) ;
els . nudgeRight ? . addEventListener ( 'click' , ( ) => nudgeEditor ( 1 , 0 ) ) ;
els . nudgeUp ? . addEventListener ( 'click' , ( ) => nudgeEditor ( 0 , - 1 ) ) ;
els . nudgeDown ? . addEventListener ( 'click' , ( ) => nudgeEditor ( 0 , 1 ) ) ;
els . exportPng ? . addEventListener ( 'click' , exportEditorPng ) ;
els . importPng ? . addEventListener ( 'click' , ( ) => els . pngImportInput ? . click ( ) ) ;
els . pngImportInput ? . addEventListener ( 'change' , importEditorPng ) ;
els . undoPaint ? . addEventListener ( 'click' , undoEditor ) ;
els . redoPaint ? . addEventListener ( 'click' , redoEditor ) ;
window . addEventListener ( 'keydown' , onEditorKeyDown ) ;
2026-06-01 18:27:25 +09:00
els . voteUp ? . addEventListener ( 'click' , ( ) => voteSelected ( 1 ) ) ;
els . voteDown ? . addEventListener ( 'click' , ( ) => voteSelected ( - 1 ) ) ;
els . bubbleRemix ? . addEventListener ( 'click' , ( ) => remixSelected ( ) ) ;
2026-06-02 16:26:44 +09:00
els . bubbleTeleport ? . addEventListener ( 'click' , ( ) => teleportToRemixSource ( ) ) ;
els . bubbleMenu ? . addEventListener ( 'click' , ( ) => toggleBubbleMenu ( ) ) ;
els . bubbleReport ? . addEventListener ( 'click' , ( ) => openReportDialog ( ) ) ;
2026-06-02 00:05:03 +09:00
els . reportCancel ? . addEventListener ( 'click' , ( ) => closeReportDialog ( ) ) ;
els . reportSubmit ? . addEventListener ( 'click' , ( ) => submitReportDialog ( ) ) ;
els . reportDialog ? . addEventListener ( 'click' , ( event ) => { if ( event . target === els . reportDialog ) closeReportDialog ( ) ; } ) ;
2026-06-01 18:27:25 +09:00
els . bubbleHide ? . addEventListener ( 'click' , ( ) => hideSelected ( ) ) ;
els . paintCanvas . addEventListener ( 'pointerdown' , onPaintPointerDown ) ;
els . paintCanvas . addEventListener ( 'pointermove' , onPaintPointerMove ) ;
els . paintCanvas . addEventListener ( 'pointerup' , onPaintPointerUp ) ;
els . paintCanvas . addEventListener ( 'pointercancel' , onPaintPointerUp ) ;
els . paintCanvas . addEventListener ( 'mousedown' , onPaintMouseDown ) ;
window . addEventListener ( 'mousemove' , onPaintMouseMove ) ;
window . addEventListener ( 'mouseup' , onPaintMouseUp ) ;
els . paintCanvas . addEventListener ( 'wheel' , onPaintWheel , { passive : false } ) ;
els . paintCanvas . addEventListener ( 'click' , onPaintClick ) ;
2026-06-01 22:48:15 +09:00
window . addEventListener ( 'pointerup' , ( ) => { isPainting = false ; lastPaintedKey = '' ; editorPointer . panning = false ; finishEditorGesture ( ) ; } ) ;
2026-06-01 18:27:25 +09:00
els . paintCanvas . addEventListener ( 'contextmenu' , ( event ) => event . preventDefault ( ) ) ;
els . saveAsset . addEventListener ( 'click' , saveAssetFromEditor ) ;
els . saveAndPlace . addEventListener ( 'click' , saveAndPlaceFromEditor ) ;
2026-06-02 02:35:22 +09:00
els . checkOnIsland ? . addEventListener ( 'click' , checkCurrentEditorOnIsland ) ;
2026-06-02 16:26:44 +09:00
els . newAsset ? . addEventListener ( 'click' , newAsset ) ;
2026-06-01 18:27:25 +09:00
els . exportData . addEventListener ( 'click' , exportData ) ;
els . importData . addEventListener ( 'click' , importData ) ;
2026-06-01 22:48:15 +09:00
els . exportCompact ? . addEventListener ( 'click' , exportCompactData ) ;
els . exportSnapshot ? . addEventListener ( 'click' , exportSnapshotData ) ;
els . exportAssetBundle ? . addEventListener ( 'click' , exportAssetBundleData ) ;
2026-06-01 18:27:25 +09:00
els . resetAll . addEventListener ( 'click' , resetAll ) ;
2026-06-02 00:05:03 +09:00
els . validateWorld ? . addEventListener ( 'click' , validateWorld ) ;
els . exportModerationReport ? . addEventListener ( 'click' , exportModerationReport ) ;
els . clearReports ? . addEventListener ( 'click' , clearModerationReports ) ;
2026-06-01 21:17:09 +09:00
els . showHiddenAssets ? . addEventListener ( 'click' , ( ) => {
els . hiddenAssetPanel . hidden = ! els . hiddenAssetPanel . hidden ;
renderHiddenAssets ( ) ;
} ) ;
2026-06-01 18:27:25 +09:00
}
function resizeCanvas ( ) {
dpr = window . devicePixelRatio || 1 ;
cw = Math . max ( 1 , window . innerWidth ) ;
ch = Math . max ( 1 , window . innerHeight ) ;
els . canvas . width = Math . floor ( cw * dpr ) ;
els . canvas . height = Math . floor ( ch * dpr ) ;
els . canvas . style . width = ` ${ cw } px ` ;
els . canvas . style . height = ` ${ ch } px ` ;
ctx . setTransform ( dpr , 0 , 0 , dpr , 0 , 0 ) ;
}
function resetView ( announce ) {
const bounds = terrainCache . bounds ;
view . zoom = Math . min ( 1.05 , Math . max ( MIN _ZOOM , Math . min ( cw / bounds . w , ch / bounds . h ) * 1.18 ) ) ;
view . x = cw / 2 - ( bounds . x + bounds . w / 2 ) * view . zoom ;
view . y = ch / 2 - ( bounds . y + bounds . h / 2 ) * view . zoom + 34 ;
if ( announce ) toast ( 'View reset.' ) ;
}
function setDrawerOpen ( open ) {
els . drawer . classList . toggle ( 'open' , open ) ;
els . openEditor . hidden = open ;
if ( ! open ) {
selectedObject = null ;
updateSelectionBubble ( performance . now ( ) ) ;
}
}
function onDocumentContextMenu ( event ) {
if ( ! els . drawer ? . classList . contains ( 'open' ) ) return ;
2026-06-02 16:26:44 +09:00
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 ;
2026-06-01 18:27:25 +09:00
event . preventDefault ( ) ;
setDrawerOpen ( false ) ;
}
function setTab ( name ) {
els . tabs . forEach ( ( button ) => button . classList . toggle ( 'active' , button . dataset . tab === name ) ) ;
els . panels . forEach ( ( panel ) => panel . classList . toggle ( 'active' , panel . id === ` tab- ${ name } ` ) ) ;
}
function setMode ( nextMode ) {
mode = nextMode ;
2026-06-02 16:26:44 +09:00
if ( mode === 'place' || mode === 'erase' ) clearWorldSelection ( false ) ;
2026-06-01 18:27:25 +09:00
const isInspect = mode === 'inspect' ;
const isPlace = mode === 'place' ;
const isErase = mode === 'erase' ;
[ els . modeInspect , els . drawerInspect ] . filter ( Boolean ) . forEach ( ( el ) => el . classList . toggle ( 'active' , isInspect ) ) ;
[ els . modePlace , els . drawerPlace ] . filter ( Boolean ) . forEach ( ( el ) => el . classList . toggle ( 'active' , isPlace ) ) ;
[ els . modeErase , els . drawerErase ] . filter ( Boolean ) . forEach ( ( el ) => el . classList . toggle ( 'active' , isErase ) ) ;
els . canvas . classList . toggle ( 'placeCursor' , isPlace ) ;
els . canvas . classList . toggle ( 'eraseCursor' , isErase ) ;
}
function refreshCategoryUI ( ) {
2026-06-02 16:26:44 +09:00
els . drawer ? . classList . toggle ( 'advancedTools' , advancedDraw ) ;
2026-06-01 18:27:25 +09:00
const role = currentRole ( ) ;
const isStatic = roleToCategory ( role ) === 'static' ;
if ( isStatic ) staticKind = roleToSubtype ( role ) ;
else dynamicKind = roleToSubtype ( role ) ;
toggleHidden ( els . staticKindWrap , true ) ;
toggleHidden ( els . dynamicKindWrap , true ) ;
toggleHidden ( els . sideSwitcher , isStatic ) ;
toggleHidden ( els . staticSettingsPanel , ! isStatic ) ;
toggleHidden ( els . dynamicSettingsPanel , true ) ;
2026-06-02 16:26:44 +09:00
toggleHidden ( els . advancedToolGroup , ! advancedDraw ) ;
2026-06-01 21:17:09 +09:00
toggleHidden ( els . toolLight , ! advancedDraw ) ;
2026-06-02 02:35:22 +09:00
toggleHidden ( els . toolParticle , ! advancedDraw ) ;
2026-06-01 21:17:09 +09:00
toggleHidden ( els . toolDepth , ! advancedDraw ) ;
2026-06-02 16:26:44 +09:00
toggleHidden ( els . particleDirectionWrap , ! advancedDraw ) ;
2026-06-01 18:27:25 +09:00
toggleHidden ( els . toolDoor , role !== 'building' ) ;
toggleHidden ( els . doorMarkerHint , role !== 'building' ) ;
2026-06-02 02:35:22 +09:00
if ( ! advancedDraw && ( paintTool === 'light' || paintTool === 'depth' || paintTool === 'particle' ) ) setPaintTool ( 'brush' ) ;
2026-06-01 18:27:25 +09:00
if ( ! isStatic && paintTool === 'door' ) setPaintTool ( 'brush' ) ;
if ( isStatic && role !== 'building' && paintTool === 'door' ) setPaintTool ( 'brush' ) ;
if ( isStatic ) editingSide = 'right' ;
updateRoleHint ( ) ;
updateSideButtons ( ) ;
clampEditorView ( ) ;
drawEditor ( ) ;
2026-06-02 16:26:44 +09:00
updateParticleUI ( ) ;
2026-06-01 18:27:25 +09:00
updateSettingsSummary ( ) ;
}
function updateRoleHint ( ) {
if ( ! els . roleHint ) return ;
const role = currentRole ( ) ;
2026-06-01 21:17:09 +09:00
const textMap = {
2026-06-02 16:26:44 +09:00
human : 'Humans move by target direction. Draw as ▶ Right, or press ◀ Left if your canvas is left-facing so Save mirrors it.' ,
animal : 'Animals move by target direction. Draw as ▶ Right, or press ◀ Left if your canvas is left-facing so Save mirrors it.' ,
2026-06-01 21:17:09 +09:00
nature : 'Nature attracts animals and birds. Static sprites are drawn at 2× scale.' ,
2026-06-01 18:27:25 +09:00
building : 'Buildings attract humans. Use Door to mark the entrance.' ,
2026-06-02 00:05:03 +09:00
ship : 'Ships are static water objects. They float and emit ring ripples.' ,
2026-06-01 21:17:09 +09:00
other : 'Other objects are neutral scenery and render at 2× scale.'
2026-06-01 18:27:25 +09:00
} ;
2026-06-01 21:17:09 +09:00
els . roleHint . textContent = textMap [ role ] || textMap . other ;
2026-06-01 18:27:25 +09:00
}
function updateSettingsSummary ( ) {
if ( ! els . settingsSummary ) return ;
const role = currentRole ( ) ;
2026-06-01 21:17:09 +09:00
if ( roleToCategory ( role ) === 'static' ) {
2026-06-01 18:27:25 +09:00
const lightCount = lightPixels . length ;
const lightText = lightCount ? ` ${ lightCount } lamp cell ${ lightCount === 1 ? '' : 's' } ` : 'no light' ;
2026-06-02 16:26:44 +09:00
const particleText = particlePixels . length ? ` particles ${ particlePixels . length } cell ${ particlePixels . length === 1 ? '' : 's' } ` : 'no particles' ;
2026-06-01 18:27:25 +09:00
const doorText = role === 'building' ? ` / door ${ Math . round ( doorPixel . x ) } , ${ Math . round ( doorPixel . y ) } ` : '' ;
2026-06-02 02:35:22 +09:00
els . settingsSummary . textContent = ` ${ cap ( role ) } / 2× static pixels / ${ lightText } / ${ particleText } ${ doorText } . ` ;
2026-06-01 18:27:25 +09:00
} else {
2026-06-02 16:26:44 +09:00
const particleText = particlePixels . length ? ` / particles ${ particlePixels . length } cell ${ particlePixels . length === 1 ? '' : 's' } ` : '' ;
const sideText = editingSide === 'left' ? 'canvas marked ◀ Left; Save mirrors it into canonical ▶ Right' : 'canvas marked canonical ▶ Right' ;
els . settingsSummary . textContent = ` ${ cap ( role ) } / ${ sideText } ${ particleText } . ` ;
2026-06-01 18:27:25 +09:00
}
}
function setPaintTool ( tool ) {
paintTool = tool ;
2026-06-02 02:35:22 +09:00
[ els . toolBrush , els . toolErase , els . toolFill , els . toolPick , els . toolLine , els . toolRect , els . toolSelect , els . toolLight , els . toolParticle , els . toolDoor , els . toolDepth ] . filter ( Boolean ) . forEach ( ( button ) => button . classList . remove ( 'active' ) ) ;
( { brush : els . toolBrush , erase : els . toolErase , fill : els . toolFill , pick : els . toolPick , line : els . toolLine , rect : els . toolRect , select : els . toolSelect , light : els . toolLight , particle : els . toolParticle , door : els . toolDoor , depth : els . toolDepth } [ tool ] ) ? . classList . add ( 'active' ) ;
2026-06-01 18:27:25 +09:00
const hints = {
brush : 'Draw pixels with the selected palette color.' ,
2026-06-02 16:26:44 +09:00
erase : 'Erase metadata first when Light/Depth/Particle exists on the cell; erase the pixel on the next click.' ,
2026-06-01 22:48:15 +09:00
fill : 'Fill a connected area with the selected palette color. Hold Shift to fill with transparency.' ,
pick : 'Pick a color from the canvas and return to Draw.' ,
line : 'Drag to draw a straight line. Right-click erases along the line.' ,
rect : 'Drag to draw a rectangle. Hold Shift for a filled rectangle; right-click erases.' ,
2026-06-02 16:26:44 +09:00
select : 'Left-click/drag to select pixels. Right-click clears the selection.' ,
2026-06-01 18:27:25 +09:00
light : 'Paint light cells with the selected palette color. Hold Shift to erase light cells.' ,
2026-06-02 16:26:44 +09:00
particle : 'Particle emits from the sprite or selected range. Palette chooses color; direction is set below. Shift-click disables particles; right-click pans.' ,
2026-06-01 21:17:09 +09:00
door : 'Click one pixel to mark a building door. Humans will enter near this point.' ,
2026-06-02 16:26:44 +09:00
depth : 'Advanced: paint high or low depth. Use High/Low buttons; Shift/right-click clears.'
2026-06-01 18:27:25 +09:00
} ;
2026-06-01 22:48:15 +09:00
els . editHint . textContent = hints [ tool ] || hints . brush ;
2026-06-02 16:26:44 +09:00
[ els . depthHigh , els . depthLow ] . filter ( Boolean ) . forEach ( ( button ) => button . classList . remove ( 'active' ) ) ;
if ( tool === 'depth' ) ( { 1 : els . depthHigh , '-1' : els . depthLow } [ depthPaintMode ] ) ? . classList . add ( 'active' ) ;
updateParticleUI ( ) ;
2026-06-01 22:48:15 +09:00
}
function setDepthPaintMode ( mode ) {
2026-06-02 16:26:44 +09:00
depthPaintMode = mode === - 1 ? - 1 : 1 ;
[ els . depthHigh , els . depthLow ] . filter ( Boolean ) . forEach ( ( button ) => button . classList . remove ( 'active' ) ) ;
( { 1 : els . depthHigh , '-1' : els . depthLow } [ depthPaintMode ] ) ? . classList . add ( 'active' ) ;
2026-06-01 22:48:15 +09:00
if ( paintTool !== 'depth' ) setPaintTool ( 'depth' ) ;
2026-06-02 16:26:44 +09:00
const label = depthPaintMode > 0 ? 'high' : 'low' ;
if ( els . editHint ) els . editHint . textContent = ` Depth mode: ${ label } . Shift/right-click clears depth. ` ;
2026-06-01 18:27:25 +09:00
}
function setEditingSide ( side ) {
if ( roleToCategory ( currentRole ( ) ) !== 'dynamic' ) return ;
2026-06-02 16:26:44 +09:00
editingSide = side === 'left' ? 'left' : 'right' ;
2026-06-01 18:27:25 +09:00
updateSideButtons ( ) ;
2026-06-02 16:26:44 +09:00
if ( els . editHint ) {
els . editHint . textContent = editingSide === 'left'
? 'Canvas is marked as left-facing. It will be mirrored into the canonical right-facing sprite on save.'
: 'Canvas is marked as the canonical right-facing sprite. Movement uses this for rightward travel.' ;
}
2026-06-01 18:27:25 +09:00
drawEditor ( ) ;
}
function updateSideButtons ( ) {
if ( els . editRight ) els . editRight . classList . toggle ( 'active' , editingSide === 'right' ) ;
if ( els . editLeft ) els . editLeft . classList . toggle ( 'active' , editingSide === 'left' ) ;
updateSettingsSummary ( ) ;
}
function onWorldPointerDown ( event ) {
event . preventDefault ( ) ;
2026-06-01 22:48:15 +09:00
if ( event . button === 2 ) {
2026-06-02 16:26:44 +09:00
cancelPlacementPreview ( false ) ;
clearWorldSelection ( true ) ;
setMode ( 'inspect' ) ;
2026-06-01 22:48:15 +09:00
pointer . down = false ;
pointer . id = null ;
return ;
}
2026-06-01 18:27:25 +09:00
els . canvas . setPointerCapture ( event . pointerId ) ;
pointer = {
down : true ,
id : event . pointerId ,
startX : event . clientX ,
startY : event . clientY ,
lastX : event . clientX ,
lastY : event . clientY ,
dragging : false ,
2026-06-01 22:48:15 +09:00
downTime : performance . now ( ) ,
button : event . button || 0
2026-06-01 18:27:25 +09:00
} ;
els . canvas . classList . add ( 'dragging' ) ;
}
function onWorldPointerMove ( event ) {
const pos = getCanvasPoint ( event ) ;
hoverTile = screenToTile ( pos . x , pos . y ) ;
updateTileInfo ( ) ;
if ( ! pointer . down || pointer . id !== event . pointerId ) return ;
const dx = event . clientX - pointer . lastX ;
const dy = event . clientY - pointer . lastY ;
const total = Math . hypot ( event . clientX - pointer . startX , event . clientY - pointer . startY ) ;
if ( total > 3 ) pointer . dragging = true ;
if ( pointer . dragging ) {
view . x += dx ;
view . y += dy ;
}
pointer . lastX = event . clientX ;
pointer . lastY = event . clientY ;
}
function onWorldPointerUp ( event ) {
if ( ! pointer . down || pointer . id !== event . pointerId ) return ;
els . canvas . classList . remove ( 'dragging' ) ;
const total = Math . hypot ( event . clientX - pointer . startX , event . clientY - pointer . startY ) ;
const elapsed = performance . now ( ) - pointer . downTime ;
const wasClick = total < 6 && elapsed < 600 ;
const pos = getCanvasPoint ( event ) ;
const tile = screenToTile ( pos . x , pos . y ) ;
if ( wasClick ) {
const pickedObject = pickObjectAtScreen ( pos . x , pos . y , performance . now ( ) ) ;
if ( mode === 'place' && tile ) placeSelected ( tile . x , tile . y ) ;
else if ( mode === 'erase' ) {
if ( pickedObject ) eraseObject ( pickedObject . kind , pickedObject . id ) ;
else if ( tile ) eraseAt ( tile . x , tile . y ) ;
} else if ( pickedObject ) {
selectWorldObject ( pickedObject . kind , pickedObject . id , pickedObject . assetId , performance . now ( ) ) ;
const asset = findAsset ( pickedObject . assetId ) ;
toast ( asset ? ` Selected ${ asset . name } . ` : 'Selected object.' ) ;
} else if ( tile ) inspectAt ( tile . x , tile . y ) ;
}
pointer . down = false ;
pointer . id = null ;
pointer . dragging = false ;
}
function onWorldWheel ( event ) {
event . preventDefault ( ) ;
const pos = getCanvasPoint ( event ) ;
const factor = event . deltaY > 0 ? 1 / 1.13 : 1.13 ;
zoomAt ( pos . x , pos . y , view . zoom * factor ) ;
}
function zoomAt ( screenX , screenY , nextZoom ) {
nextZoom = clamp ( nextZoom , MIN _ZOOM , MAX _ZOOM ) ;
const worldX = ( screenX - view . x ) / view . zoom ;
const worldY = ( screenY - view . y ) / view . zoom ;
view . zoom = nextZoom ;
view . x = screenX - worldX * view . zoom ;
view . y = screenY - worldY * view . zoom ;
}
function getCanvasPoint ( event ) {
const rect = els . canvas . getBoundingClientRect ( ) ;
return { x : event . clientX - rect . left , y : event . clientY - rect . top } ;
}
function screenToTile ( screenX , screenY ) {
const worldX = ( screenX - view . x ) / view . zoom ;
const worldY = ( screenY - view . y ) / view . zoom ;
const localX = worldX - ORIGIN _X ;
const localY = worldY - ORIGIN _Y ;
const a = localX / ( TILE _W / 2 ) ;
const b = localY / ( TILE _H / 2 ) ;
const baseX = Math . floor ( ( a + b ) / 2 ) ;
const baseY = Math . floor ( ( b - a ) / 2 ) ;
let best = null ;
let bestScore = - Infinity ;
for ( let ty = baseY - 2 ; ty <= baseY + 2 ; ty ++ ) {
for ( let tx = baseX - 2 ; tx <= baseX + 2 ; tx ++ ) {
if ( tx < 0 || ty < 0 || tx >= WORLD _W || ty >= WORLD _H ) continue ;
const tile = world . get ( tx , ty ) ;
const center = tileToWorld ( tx , ty ) ;
const lift = getTileLift ( tile ) ;
const nx = Math . abs ( worldX - center . x ) / ( TILE _W / 2 ) ;
const ny = Math . abs ( worldY - ( center . y + TILE _H / 2 - lift ) ) / ( TILE _H / 2 ) ;
if ( nx + ny <= 1 ) {
const score = lift * 100 + ( tx + ty ) ;
if ( score > bestScore ) {
bestScore = score ;
best = { x : tx , y : ty , tile } ;
}
}
}
}
return best ;
}
function tileToWorld ( x , y ) {
return {
x : ( x - y ) * TILE _W / 2 + ORIGIN _X ,
y : ( x + y ) * TILE _H / 2 + ORIGIN _Y
} ;
}
2026-06-01 22:48:15 +09:00
function tileKey ( x , y ) {
return ` ${ x } , ${ y } ` ;
}
function makeEmptyWorldIndex ( ) {
return { placedByTile : new Map ( ) , dynamicByHomeTile : new Map ( ) } ;
}
function pushIndexBucket ( map , key , item ) {
const bucket = map . get ( key ) ;
if ( bucket ) bucket . push ( item ) ;
else map . set ( key , [ item ] ) ;
}
function rebuildWorldIndex ( ) {
const next = makeEmptyWorldIndex ( ) ;
for ( const placed of state . placed || [ ] ) {
pushIndexBucket ( next . placedByTile , tileKey ( placed . x , placed . y ) , placed ) ;
}
for ( const summon of state . dynamicSummons || [ ] ) {
pushIndexBucket ( next . dynamicByHomeTile , tileKey ( Math . round ( summon . homeX ) , Math . round ( summon . homeY ) ) , summon ) ;
}
worldIndex = next ;
}
function getPlacedAtTile ( x , y ) {
return worldIndex . placedByTile . get ( tileKey ( x , y ) ) || [ ] ;
}
function getDynamicHomesAtTile ( x , y ) {
return worldIndex . dynamicByHomeTile . get ( tileKey ( x , y ) ) || [ ] ;
}
2026-06-01 18:27:25 +09:00
2026-06-02 02:35:22 +09:00
function getLocalDisplayLimit ( ) {
const settings = visualSettings ( ) ;
return clampInt ( settings . localDisplayLimit , 25 , 500 , PHASE5 _GUARDRAILS . defaultDisplayLimit ) ;
}
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 ;
}
function getObjectPublicAt ( kind , object ) {
return Number ( object ? . publishedAt || object ? . placedAt || object ? . createdAt || Date . now ( ) ) ;
}
function isPermanentlyHiddenObject ( object ) {
return object ? . status === 'permanent_hidden' || object ? . status === 'violation_hidden' || object ? . permanentHidden === true ;
}
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' ) ;
}
function isServerSuppressedObject ( object ) {
return [ 'archived' , 'permanent_hidden' , 'violation_hidden' ] . includes ( object ? . status ) ;
}
function getObjectRotationEntry ( kind , object , baseIndex = 0 ) {
const objectId = object ? . id ;
const votes = getObjectVoteCounts ( objectId ) ;
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
} ;
}
function getRotationEntries ( includeLocalHidden = false ) {
const entries = [ ] ;
for ( const placed of state . placed || [ ] ) {
const asset = findAsset ( placed . assetId ) ;
if ( ! asset || isModeratedAssetHidden ( asset ) || state . hiddenAssets ? . [ asset . id ] || isServerSuppressedObject ( placed ) ) continue ;
if ( ! includeLocalHidden && state . hiddenObjects ? . [ placed . id ] ) continue ;
entries . push ( { kind : 'static' , object : placed } ) ;
}
for ( const summon of state . dynamicSummons || [ ] ) {
const asset = findAsset ( summon . assetId ) ;
if ( ! asset || isModeratedAssetHidden ( asset ) || state . hiddenAssets ? . [ asset . id ] || isServerSuppressedObject ( summon ) ) continue ;
if ( ! includeLocalHidden && state . hiddenObjects ? . [ summon . id ] ) continue ;
entries . push ( { kind : 'dynamic' , object : summon } ) ;
}
entries . sort ( ( a , b ) => getObjectPublicAt ( a . kind , a . object ) - getObjectPublicAt ( b . kind , b . object ) || String ( a . object . id ) . localeCompare ( String ( b . object . id ) ) ) ;
return entries . map ( ( entry , index ) => getObjectRotationEntry ( entry . kind , entry . object , index ) ) ;
}
function seededScore ( id , salt = '' ) {
const hash = fnv1a ( ` ${ id } | ${ salt } | ${ Math . floor ( ( state . lastRotationAt || Date . now ( ) ) / ( 24 * 60 * 60 * 1000 ) ) } ` ) ;
return parseInt ( hash . slice ( 0 , 8 ) , 16 ) / 0xffffffff ;
}
function getIslandDisplayBuckets ( ) {
const entries = getRotationEntries ( false ) ;
const localLimit = getLocalDisplayLimit ( ) ;
const newCap = Math . min ( PHASE5 _GUARDRAILS . newArrivalSlots , localLimit ) ;
const revivalCap = Math . max ( 0 , Math . min ( PHASE5 _GUARDRAILS . 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 ( ( entry ) => entry . id ) ) ;
const revivalPool = entries . filter ( ( entry ) => ! newestIds . has ( entry . id ) ) ;
const revival = revivalPool
. slice ( )
. sort ( ( a , b ) => seededScore ( b . id , 'revival' ) - seededScore ( a . id , 'revival' ) || b . up - a . up || String ( b . id ) . localeCompare ( String ( a . id ) ) )
. slice ( 0 , revivalCap ) ;
return { newest , revival , entries , visibleIds : new Set ( [ ... newest , ... revival ] . map ( ( entry ) => entry . id ) ) } ;
}
function getVisibleRotationIdSet ( ) {
return getIslandDisplayBuckets ( ) . visibleIds ;
}
function isWorldObjectVisibleByRotation ( kind , objectId ) {
const object = getObjectRecord ( kind , objectId ) ;
if ( ! object || isServerSuppressedObject ( object ) || state . hiddenObjects ? . [ objectId ] ) return false ;
return getVisibleRotationIdSet ( ) . has ( objectId ) ;
}
function updateRotationStats ( ) {
if ( ! els . rotationStats ) return ;
const buckets = getIslandDisplayBuckets ( ) ;
const entries = buckets . entries ;
const visible = buckets . visibleIds . size ;
const hiddenByRotation = Math . max ( 0 , entries . length - visible ) ;
const limit = getLocalDisplayLimit ( ) ;
const publish = getPublishQuotaStatus ( ) ;
const quotaText = publish . accountRequired ? 'account required to publish' : ` publish quota ${ publish . used } / ${ publish . limit } this hour ` ;
els . rotationStats . textContent = ` Island exhibition ${ visible } / ${ entries . length } objects · newest ${ buckets . newest . length } /150 · revival ${ buckets . revival . length } /100 · local cap ${ limit } · rotated out ${ hiddenByRotation } · ${ quotaText } . ` ;
updateAccountUI ( ) ;
}
function getAccountAgeMs ( now = Date . now ( ) ) {
return state . account ? . createdAt ? now - Number ( state . account . createdAt ) : 0 ;
}
function currentPublishLimit ( now = Date . now ( ) ) {
if ( ! state . account ? . createdAt ) return 0 ;
return getAccountAgeMs ( now ) < 24 * 60 * 60 * 1000
? PHASE5 _GUARDRAILS . publishLimitFirstDay
: PHASE5 _GUARDRAILS . publishLimitTrusted ;
}
function getPublishQuotaStatus ( now = Date . now ( ) ) {
state . publishLog = Array . isArray ( state . publishLog ) ? state . publishLog : [ ] ;
const author = currentVoterKey ( ) ;
const windowMs = 60 * 60 * 1000 ;
state . publishLog = state . publishLog . filter ( ( entry ) => now - Number ( entry . at || 0 ) < windowMs * 24 ) ;
const used = state . publishLog . filter ( ( entry ) => entry . author === author && now - Number ( entry . at || 0 ) < windowMs ) . length ;
const limit = currentPublishLimit ( now ) ;
return { used , limit , remaining : Math . max ( 0 , limit - used ) , accountRequired : ! state . account ? . createdAt } ;
}
function canPublishObject ( action = 'publish' ) {
2026-06-02 16:26:44 +09:00
if ( ! state . account ? . createdAt ) ensureLocalAccount ( action === 'publish' ? 'publish' : 'republish' ) ;
2026-06-02 02:35:22 +09:00
const quota = getPublishQuotaStatus ( ) ;
if ( quota . accountRequired ) {
toast ( 'Create a local account before publishing works to the island.' ) ;
updateAccountUI ( ) ;
return false ;
}
if ( quota . remaining <= 0 ) {
toast ( ` Publish limit reached: ${ quota . limit } public placements per hour. ` ) ;
return false ;
}
return true ;
}
function recordObjectPublish ( kind , object , action = 'publish' ) {
if ( ! object ) return ;
const now = Date . now ( ) ;
object . publishedAt = now ;
object . status = 'active' ;
state . publishLog = Array . isArray ( state . publishLog ) ? state . publishLog : [ ] ;
state . publishLog . push ( { at : now , author : currentVoterKey ( ) , kind , objectId : object . id , assetId : object . assetId , action } ) ;
state . publishLog = state . publishLog . slice ( - 300 ) ;
}
2026-06-02 16:26:44 +09:00
function shouldHideUnderPlacementPreview ( assetId , x , y ) {
return false ;
}
2026-06-01 22:48:15 +09:00
function getDrawableItems ( time = performance . now ( ) , viewport = null ) {
const rect = viewport || getViewportWorldRect ( VIEW _CULL _MARGIN ) ;
2026-06-01 18:27:25 +09:00
const items = [ ] ;
for ( const placed of state . placed ) {
const asset = findAsset ( placed . assetId ) ;
2026-06-02 02:35:22 +09:00
if ( ! asset || isModeratedAssetHidden ( asset ) || state . hiddenAssets ? . [ asset . id ] || state . hiddenObjects ? . [ placed . id ] || isPermanentlyHiddenObject ( placed ) || ! isWorldObjectVisibleByRotation ( 'static' , placed . id ) ) continue ;
2026-06-02 16:26:44 +09:00
if ( shouldHideUnderPlacementPreview ( asset . id , placed . x + . 5 , placed . y + . 5 ) ) continue ;
2026-06-01 22:48:15 +09:00
const itemX = placed . x + . 5 ;
const itemY = placed . y + . 5 ;
if ( ! isApproxVisible ( asset , itemX , itemY , rect ) ) continue ;
items . push ( { kind : 'static' , asset , x : itemX , y : itemY , source : placed } ) ;
2026-06-01 18:27:25 +09:00
}
for ( const runtime of dynamicRuntime ) {
const asset = findAsset ( runtime . assetId ) ;
2026-06-02 02:35:22 +09:00
if ( ! asset || isModeratedAssetHidden ( asset ) || state . hiddenAssets ? . [ asset . id ] || state . hiddenObjects ? . [ runtime . id ] || isPermanentlyHiddenObject ( runtime ) || ! isWorldObjectVisibleByRotation ( 'dynamic' , runtime . id ) ) continue ;
2026-06-02 16:26:44 +09:00
if ( shouldHideUnderPlacementPreview ( asset . id , runtime . x , runtime . y ) ) continue ;
2026-06-01 18:27:25 +09:00
if ( time < runtime . hiddenUntil ) continue ;
2026-06-01 22:48:15 +09:00
if ( ! isApproxVisible ( asset , runtime . x , runtime . y , rect ) ) continue ;
2026-06-01 18:27:25 +09:00
items . push ( { kind : 'dynamic' , asset , x : runtime . x , y : runtime . y , source : runtime } ) ;
}
2026-06-02 02:35:22 +09:00
if ( placementPreview ? . asset && placementPreview . x != null && placementPreview . y != null ) {
const asset = placementPreview . asset ;
items . push ( { kind : asset . category === 'dynamic' ? 'dynamic' : 'static' , asset , x : placementPreview . x + . 5 , y : placementPreview . y + . 5 , source : { id : 'placement-preview' , preview : true } , preview : true } ) ;
}
2026-06-01 22:48:15 +09:00
items . sort ( drawOrderCompare ) ;
2026-06-01 18:27:25 +09:00
return items ;
}
2026-06-02 16:26:44 +09:00
function getObjectSortBottom ( item ) {
const pos = tileToWorld ( item . x , item . y ) ;
const asset = item . asset || { } ;
if ( ! ( asset . category === 'dynamic' && asset . subtype === 'bird' ) ) pos . y -= getLiftAtCoord ( item . x , item . y ) ;
const anchorY = asset . category === 'static' ? 0 : TILE _H / 2 ;
return pos . y + anchorY ;
}
2026-06-01 22:48:15 +09:00
function drawOrderCompare ( a , b ) {
2026-06-02 16:26:44 +09:00
const bottomDelta = getObjectSortBottom ( a ) - getObjectSortBottom ( b ) ;
if ( Math . abs ( bottomDelta ) > 0.01 ) return bottomDelta ;
2026-06-01 22:48:15 +09:00
return ( a . x + a . y ) - ( b . x + b . y ) || a . y - b . y || String ( a . source ? . id || '' ) . localeCompare ( String ( b . source ? . id || '' ) ) ;
}
function getViewportWorldRect ( margin = 0 ) {
const invZoom = 1 / Math . max ( 0.0001 , view . zoom ) ;
return {
left : - view . x * invZoom - margin ,
top : - view . y * invZoom - margin ,
right : ( cw - view . x ) * invZoom + margin ,
bottom : ( ch - view . y ) * invZoom + margin
} ;
}
function isApproxVisible ( asset , x , y , rect ) {
if ( ! rect ) return true ;
const pos = tileToWorld ( x , y ) ;
if ( ! ( asset . category === 'dynamic' && asset . subtype === 'bird' ) ) pos . y -= getLiftAtCoord ( x , y ) ;
const scale = asset . category === 'static' ? STATIC _SCALE : DYNAMIC _SCALE ;
const w = asset . size * scale + 96 ;
const h = asset . size * scale + 128 ;
return pos . x + w >= rect . left && pos . x - w <= rect . right && pos . y + 80 >= rect . top && pos . y - h <= rect . bottom ;
}
2026-06-01 18:27:25 +09:00
function pickObjectAtScreen ( screenX , screenY , time = performance . now ( ) ) {
const worldX = ( screenX - view . x ) / view . zoom ;
const worldY = ( screenY - view . y ) / view . zoom ;
2026-06-01 22:48:15 +09:00
const items = getDrawableItems ( time , getViewportWorldRect ( 256 ) ) ;
2026-06-01 18:27:25 +09:00
for ( let i = items . length - 1 ; i >= 0 ; i -- ) {
const item = items [ i ] ;
const info = getSpriteDrawInfo ( item , time , false ) ;
2026-06-01 22:48:15 +09:00
if ( isSpritePixelHit ( item . asset , info , worldX , worldY ) ) {
2026-06-01 18:27:25 +09:00
return { kind : item . kind , id : item . source . id , assetId : item . asset . id } ;
}
}
return null ;
}
2026-06-01 22:48:15 +09:00
function isSpritePixelHit ( asset , info , worldX , worldY ) {
const scale = asset . category === 'static' ? STATIC _SCALE : DYNAMIC _SCALE ;
let localX ;
let localY ;
if ( info . angle ) {
const pivotX = info . drawX + info . sprite . width / 2 ;
const pivotY = info . drawY + info . sprite . height * 0.8 ;
const dx = worldX - pivotX ;
const dy = worldY - pivotY ;
const cos = Math . cos ( - info . angle ) ;
const sin = Math . sin ( - info . angle ) ;
localX = dx * cos - dy * sin + info . sprite . width / 2 ;
localY = dx * sin + dy * cos + info . sprite . height * 0.8 ;
} else {
localX = worldX - info . drawX ;
localY = worldY - info . drawY ;
}
if ( localX < 0 || localY < 0 || localX >= info . sprite . width || localY >= info . sprite . height ) return false ;
2026-06-02 16:26:44 +09:00
// Tiny 8× 8 works are hard to click. Use the whole sprite box for them,
// including transparent cells, while larger works still use opaque pixels.
if ( ( asset . size || 0 ) <= 8 ) return true ;
2026-06-01 22:48:15 +09:00
const px = Math . floor ( localX / scale ) ;
const py = Math . floor ( localY / scale ) ;
if ( px < 0 || py < 0 || px >= asset . size || py >= asset . size ) return false ;
const pixels = getAssetPixels ( asset , info . side || 'right' ) ;
return Boolean ( pixels [ py * asset . size + px ] ) ;
}
2026-06-01 18:27:25 +09:00
function updateTileInfo ( ) {
if ( ! els . tileInfo ) return ;
if ( ! hoverTile ) {
els . tileInfo . textContent = 'Move over the map.' ;
return ;
}
const tile = world . get ( hoverTile . x , hoverTile . y ) ;
2026-06-02 02:35:22 +09:00
const staticCount = getPlacedAtTile ( hoverTile . x , hoverTile . y ) . filter ( ( p ) => isWorldObjectVisibleByRotation ( 'static' , p . id ) ) . length ;
const dynamicCount = getDynamicHomesAtTile ( hoverTile . x , hoverTile . y ) . filter ( ( p ) => isWorldObjectVisibleByRotation ( 'dynamic' , p . id ) ) . length ;
2026-06-01 18:27:25 +09:00
els . tileInfo . textContent = ` Tile ${ hoverTile . x } , ${ hoverTile . y } \n Terrain: ${ cap ( tile . type ) } \n Objects here: ${ staticCount } \n Dynamic homes: ${ dynamicCount } ` ;
}
2026-06-01 22:48:15 +09:00
function clearWorldSelection ( announce = false ) {
2026-06-02 16:26:44 +09:00
const hadSelection = ! ! selectedObject ;
2026-06-01 22:48:15 +09:00
selectedObject = null ;
2026-06-02 16:26:44 +09:00
if ( els . bubbleMenuActions ) els . bubbleMenuActions . hidden = true ;
2026-06-01 22:48:15 +09:00
updateSelectionBubble ( performance . now ( ) ) ;
2026-06-02 16:26:44 +09:00
if ( announce && hadSelection ) toast ( 'Selection cleared.' ) ;
}
function updateSelectedLabel ( ) {
if ( selectedAssetId && ! findAsset ( selectedAssetId ) ) {
selectedAssetId = state . assets . find ( ( asset ) => ! state . hiddenAssets ? . [ asset . id ] ) ? . id || state . assets [ 0 ] ? . id || null ;
}
const asset = findAsset ( selectedAssetId ) ;
if ( els . selectedAssetName ) {
els . selectedAssetName . textContent = asset ? ` ${ asset . name || 'Untitled' } · ${ cap ( subtypeToRole ( asset ) ) } ` : 'No collection work selected' ;
}
2026-06-01 22:48:15 +09:00
}
2026-06-01 18:27:25 +09:00
function inspectAt ( x , y ) {
const tile = world . get ( x , y ) ;
2026-06-02 02:35:22 +09:00
const staticObjects = getPlacedAtTile ( x , y ) . filter ( ( p ) => isWorldObjectVisibleByRotation ( 'static' , p . id ) ) ;
const dynamicObjects = getDynamicHomesAtTile ( x , y ) . filter ( ( p ) => isWorldObjectVisibleByRotation ( 'dynamic' , p . id ) ) ;
2026-06-02 00:05:03 +09:00
// 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 ( ) ) ;
2026-06-01 18:27:25 +09:00
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 ) } ` ] ;
2026-06-02 00:05:03 +09:00
if ( staticNames . length ) lines . push ( ` Static here: ${ staticNames . join ( ', ' ) } ` ) ;
2026-06-01 18:27:25 +09:00
if ( dynamicNames . length ) lines . push ( ` Dynamic homes: ${ dynamicNames . join ( ', ' ) } ` ) ;
if ( els . tileInfo ) els . tileInfo . textContent = lines . join ( '\n' ) ;
}
function selectWorldObject ( kind , objectId , assetId , selectedAt = performance . now ( ) ) {
selectedObject = { kind , id : objectId , assetId , selectedAt } ;
2026-06-02 16:26:44 +09:00
if ( els . bubbleMenuActions ) els . bubbleMenuActions . hidden = true ;
2026-06-01 18:27:25 +09:00
selectedAssetId = assetId ;
updateSelectedLabel ( ) ;
renderLibrary ( ) ;
updateSelectionBubble ( selectedAt ) ;
}
2026-06-02 16:26:44 +09:00
function isTileCompatibleForAsset ( asset , tile ) {
if ( ! 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' ;
if ( tile . type === 'water' ) return asset . category === 'static' && ( asset . subtype === 'water' || asset . subtype === 'ship' ) ;
return true ;
}
function getRemixSourcePositions ( asset ) {
const ids = new Set ( [ asset ? . parentAssetId , asset ? . originalAssetId ] . filter ( Boolean ) ) ;
if ( ! ids . size ) return [ ] ;
const out = [ ] ;
for ( const placed of state . placed || [ ] ) if ( ids . has ( placed . assetId ) ) out . push ( { x : placed . x , y : placed . y } ) ;
for ( const summon of state . dynamicSummons || [ ] ) if ( ids . has ( summon . assetId ) ) out . push ( { x : summon . homeX , y : summon . homeY } ) ;
return out ;
}
function avoidRemixSourceOverlap ( asset , x , y ) {
return { x , y , moved : false } ;
}
2026-06-01 18:27:25 +09:00
function placeSelected ( x , y ) {
2026-06-02 02:35:22 +09:00
if ( placementPreview ? . asset ) {
2026-06-02 16:26:44 +09:00
const adjusted = avoidRemixSourceOverlap ( placementPreview . asset , x , y ) ;
x = adjusted . x ; y = adjusted . y ;
2026-06-02 02:35:22 +09:00
const tile = world . get ( x , y ) ;
if ( ! canPlace ( placementPreview . asset , tile ) ) return ;
placementPreview . x = x ;
placementPreview . y = y ;
render ( ) ;
2026-06-02 16:26:44 +09:00
toast ( adjusted . moved ? 'Preview nudged away from the remix source. Use Place here or Back to canvas.' : 'Preview set. Use Place here or Back to canvas.' ) ;
2026-06-02 02:35:22 +09:00
return ;
}
2026-06-01 18:27:25 +09:00
const asset = findAsset ( selectedAssetId ) ;
if ( ! asset ) {
toast ( 'Select an asset first.' ) ;
setDrawerOpen ( true ) ;
setTab ( 'library' ) ;
return ;
}
2026-06-02 16:26:44 +09:00
const adjusted = avoidRemixSourceOverlap ( asset , x , y ) ;
x = adjusted . x ; y = adjusted . y ;
let tile = world . get ( x , y ) ;
2026-06-01 18:27:25 +09:00
if ( ! canPlace ( asset , tile ) ) return ;
2026-06-02 16:26:44 +09:00
if ( adjusted . moved ) toast ( 'Placed slightly away from the remix source to avoid overlap.' ) ;
2026-06-01 18:27:25 +09:00
2026-06-02 00:05:03 +09:00
const existingObject = asset . category === 'static'
? state . placed . find ( ( p ) => p . assetId === asset . id )
: state . dynamicSummons . find ( ( p ) => p . assetId === asset . id ) ;
2026-06-02 02:35:22 +09:00
const kind = asset . category === 'dynamic' ? 'dynamic' : 'static' ;
const wasLocallyHidden = existingObject ? Boolean ( state . hiddenObjects ? . [ existingObject . id ] ) : false ;
const wasRotationHidden = existingObject ? ! isWorldObjectVisibleByRotation ( kind , existingObject . id ) : false ;
const needsPublishSlot = ! existingObject || wasLocallyHidden || wasRotationHidden ;
if ( needsPublishSlot && ! canPublishObject ( existingObject ? 'republish' : 'publish' ) ) return ;
2026-06-02 00:05:03 +09:00
const isNewObject = ! existingObject ;
if ( isNewObject && getWorldObjectCount ( ) >= PHASE5 _GUARDRAILS . maxWorldObjects ) {
2026-06-02 02:35:22 +09:00
toast ( ` Local storage object limit reached ( ${ PHASE5 _GUARDRAILS . maxWorldObjects } ). Lower the display cap or remove local objects before adding more. ` ) ;
2026-06-02 00:05:03 +09:00
return ;
}
2026-06-02 02:35:22 +09:00
let object = null ;
2026-06-01 18:27:25 +09:00
if ( asset . category === 'static' ) {
const existing = state . placed . find ( ( p ) => p . assetId === asset . id ) ;
if ( existing ) {
2026-06-01 22:48:15 +09:00
existing . x = x ; existing . y = y ; existing . placedAt = Date . now ( ) ; existing . version = ( existing . version || 1 ) + 1 ;
2026-06-02 02:35:22 +09:00
if ( needsPublishSlot ) {
delete state . hiddenObjects ? . [ existing . id ] ;
recordObjectPublish ( 'static' , existing , wasLocallyHidden || wasRotationHidden ? 'republish' : 'publish' ) ;
}
object = existing ;
2026-06-01 18:27:25 +09:00
selectWorldObject ( 'static' , existing . id , asset . id , performance . now ( ) ) ;
2026-06-02 02:35:22 +09:00
toast ( needsPublishSlot ? ` ${ asset . name } republished. ` : ` ${ asset . name } moved. ` ) ;
2026-06-01 18:27:25 +09:00
} else {
2026-06-02 02:35:22 +09:00
const placed = { id : uid ( ) , assetId : asset . id , x , y , placedAt : Date . now ( ) , publishedAt : Date . now ( ) , status : 'active' , version : 1 } ;
2026-06-01 18:27:25 +09:00
state . placed . push ( placed ) ;
2026-06-02 02:35:22 +09:00
recordObjectPublish ( 'static' , placed , 'publish' ) ;
object = placed ;
2026-06-01 18:27:25 +09:00
selectWorldObject ( 'static' , placed . id , asset . id , performance . now ( ) ) ;
toast ( ` ${ asset . name } placed. ` ) ;
}
} else {
const existing = state . dynamicSummons . find ( ( p ) => p . assetId === asset . id ) ;
if ( existing ) {
2026-06-01 22:48:15 +09:00
existing . homeX = x ; existing . homeY = y ; existing . createdAt = Date . now ( ) ; existing . version = ( existing . version || 1 ) + 1 ;
2026-06-02 02:35:22 +09:00
if ( needsPublishSlot ) {
delete state . hiddenObjects ? . [ existing . id ] ;
recordObjectPublish ( 'dynamic' , existing , wasLocallyHidden || wasRotationHidden ? 'republish' : 'publish' ) ;
}
object = existing ;
toast ( needsPublishSlot ? ` ${ asset . name } republished. ` : ` ${ asset . name } moved. ` ) ;
2026-06-01 18:27:25 +09:00
} else {
2026-06-02 02:35:22 +09:00
const summon = { id : uid ( ) , assetId : asset . id , homeX : x , homeY : y , createdAt : Date . now ( ) , publishedAt : Date . now ( ) , status : 'active' , version : 1 } ;
2026-06-01 18:27:25 +09:00
state . dynamicSummons . push ( summon ) ;
2026-06-02 02:35:22 +09:00
recordObjectPublish ( 'dynamic' , summon , 'publish' ) ;
object = summon ;
2026-06-01 18:27:25 +09:00
toast ( ` ${ asset . name } summoned. ` ) ;
}
hydrateRuntime ( ) ;
const found = state . dynamicSummons . find ( ( p ) => p . assetId === asset . id ) ;
if ( found ) selectWorldObject ( 'dynamic' , found . id , asset . id , performance . now ( ) ) ;
}
2026-06-01 22:48:15 +09:00
rebuildWorldIndex ( ) ;
2026-06-02 02:35:22 +09:00
recordSyncEvent ( Phase2Sync ? . createObjectUpsertEvent ? . ( kind , object ) ) ;
if ( visualSettings ( ) . enableParticles ) spawnEffects . push ( { x : x + . 5 , y : y + . 5 , started : performance . now ( ) } ) ;
2026-06-01 18:27:25 +09:00
saveState ( ) ;
2026-06-02 02:35:22 +09:00
updateRotationStats ( ) ;
2026-06-01 18:27:25 +09:00
}
function canPlace ( asset , tile ) {
if ( ! tile ) return false ;
2026-06-02 00:05:03 +09:00
if ( asset . category === 'static' && ( asset . subtype === 'water' || asset . subtype === 'ship' ) && tile . type !== 'water' ) {
toast ( 'Water/ship objects need water tiles.' ) ;
2026-06-01 18:27:25 +09:00
return false ;
}
if ( asset . category === 'dynamic' && asset . subtype === 'fish' && tile . type !== 'water' ) {
toast ( 'Fish need water tiles.' ) ;
return false ;
}
if ( asset . category !== 'dynamic' || asset . subtype !== 'fish' ) {
2026-06-02 00:05:03 +09:00
if ( tile . type === 'water' && ! ( asset . category === 'static' && ( asset . subtype === 'water' || asset . subtype === 'ship' ) ) ) {
2026-06-01 18:27:25 +09:00
toast ( 'Use land for this asset.' ) ;
return false ;
}
}
return true ;
}
function eraseAt ( x , y ) {
2026-06-01 22:48:15 +09:00
const staticObjects = getPlacedAtTile ( x , y ) ;
const staticTarget = staticObjects . at ( - 1 ) ;
if ( staticTarget ) {
const index = state . placed . findIndex ( ( p ) => p . id === staticTarget . id ) ;
if ( index >= 0 ) {
const item = state . placed [ index ] ;
2026-06-01 18:27:25 +09:00
const asset = findAsset ( item . assetId ) ;
2026-06-01 22:48:15 +09:00
state . placed . splice ( index , 1 ) ;
rebuildWorldIndex ( ) ;
recordSyncEvent ( Phase2Sync ? . createObjectDeleteEvent ? . ( 'static' , item . id ) ) ;
2026-06-01 18:27:25 +09:00
if ( selectedObject ? . id === item . id ) selectedObject = null ;
saveState ( ) ;
toast ( ` ${ asset ? . name ? ? 'Object' } removed. ` ) ;
return ;
}
}
2026-06-01 22:48:15 +09:00
const dynamicObjects = getDynamicHomesAtTile ( x , y ) ;
const dynamicTarget = dynamicObjects . at ( - 1 ) ;
if ( dynamicTarget ) {
const index = state . dynamicSummons . findIndex ( ( p ) => p . id === dynamicTarget . id ) ;
if ( index >= 0 ) {
const item = state . dynamicSummons [ index ] ;
2026-06-01 18:27:25 +09:00
const asset = findAsset ( item . assetId ) ;
2026-06-01 22:48:15 +09:00
state . dynamicSummons . splice ( index , 1 ) ;
rebuildWorldIndex ( ) ;
recordSyncEvent ( Phase2Sync ? . createObjectDeleteEvent ? . ( 'dynamic' , item . id ) ) ;
2026-06-01 18:27:25 +09:00
if ( selectedObject ? . id === item . id ) selectedObject = null ;
hydrateRuntime ( ) ;
saveState ( ) ;
toast ( ` ${ asset ? . name ? ? 'Dynamic object' } removed. ` ) ;
return ;
}
}
toast ( 'Nothing to erase here.' ) ;
}
2026-06-01 22:48:15 +09:00
2026-06-01 18:27:25 +09:00
function eraseObject ( kind , objectId ) {
if ( kind === 'static' ) {
const index = state . placed . findIndex ( ( p ) => p . id === objectId ) ;
if ( index >= 0 ) {
const asset = findAsset ( state . placed [ index ] . assetId ) ;
state . placed . splice ( index , 1 ) ;
2026-06-01 22:48:15 +09:00
rebuildWorldIndex ( ) ;
recordSyncEvent ( Phase2Sync ? . createObjectDeleteEvent ? . ( 'static' , objectId ) ) ;
2026-06-01 18:27:25 +09:00
if ( selectedObject ? . id === objectId ) selectedObject = null ;
saveState ( ) ;
updateSelectionBubble ( performance . now ( ) ) ;
toast ( ` ${ asset ? . name ? ? 'Object' } removed. ` ) ;
return ;
}
}
if ( kind === 'dynamic' ) {
const index = state . dynamicSummons . findIndex ( ( p ) => p . id === objectId ) ;
if ( index >= 0 ) {
const asset = findAsset ( state . dynamicSummons [ index ] . assetId ) ;
state . dynamicSummons . splice ( index , 1 ) ;
2026-06-01 22:48:15 +09:00
rebuildWorldIndex ( ) ;
recordSyncEvent ( Phase2Sync ? . createObjectDeleteEvent ? . ( 'dynamic' , objectId ) ) ;
2026-06-01 18:27:25 +09:00
if ( selectedObject ? . id === objectId ) selectedObject = null ;
hydrateRuntime ( ) ;
saveState ( ) ;
updateSelectionBubble ( performance . now ( ) ) ;
toast ( ` ${ asset ? . name ? ? 'Dynamic object' } removed. ` ) ;
}
}
}
2026-06-01 22:48:15 +09:00
function usesRightButtonAsPaint ( ) {
2026-06-02 16:26:44 +09:00
return paintTool === 'depth' || paintTool === 'light' || paintTool === 'line' || paintTool === 'rect' || paintTool === 'erase' ;
2026-06-01 22:48:15 +09:00
}
2026-06-01 18:27:25 +09:00
function onPaintPointerDown ( event ) {
event . preventDefault ( ) ;
2026-06-02 16:26:44 +09:00
if ( paintTool === 'select' && event . button === 2 ) {
clearEditorSelection ( true ) ;
return ;
}
2026-06-01 18:27:25 +09:00
els . paintCanvas . setPointerCapture ? . ( event . pointerId ) ;
lastPaintedKey = '' ;
2026-06-01 22:48:15 +09:00
const isPan = event . button === 1 || ( event . button === 2 && ! usesRightButtonAsPaint ( ) ) ;
editorPointer = { panning : isPan , pointerId : event . pointerId , lastX : event . clientX , lastY : event . clientY , button : event . button || 0 } ;
2026-06-01 18:27:25 +09:00
isPainting = ! isPan ;
mousePaint . active = false ;
mousePaint . panning = false ;
2026-06-01 22:48:15 +09:00
if ( isPainting ) {
suppressNextPaintClick = true ;
suppressMousePaintUntil = performance . now ( ) + 350 ;
if ( handleEditorSpecialPointerDown ( event ) ) return ;
beginEditorGesture ( ) ;
paintAtClient ( event . clientX , event . clientY , event . button || 0 , event . shiftKey ) ;
if ( paintTool === 'fill' || paintTool === 'pick' ) { isPainting = false ; finishEditorGesture ( ) ; }
}
2026-06-01 18:27:25 +09:00
}
function onPaintPointerMove ( event ) {
if ( editorPointer . pointerId !== event . pointerId ) return ;
if ( editorPointer . panning ) {
event . preventDefault ( ) ;
const rect = els . paintCanvas . getBoundingClientRect ( ) ;
const sx = els . paintCanvas . width / rect . width ;
const sy = els . paintCanvas . height / rect . height ;
editorView . x += ( event . clientX - editorPointer . lastX ) * sx ;
editorView . y += ( event . clientY - editorPointer . lastY ) * sy ;
editorPointer . lastX = event . clientX ;
editorPointer . lastY = event . clientY ;
clampEditorView ( ) ;
drawEditor ( ) ;
return ;
}
2026-06-01 22:48:15 +09:00
if ( shapeGesture || selectionGesture ) {
event . preventDefault ( ) ;
handleEditorSpecialPointerMove ( event ) ;
return ;
}
2026-06-01 18:27:25 +09:00
if ( ! isPainting ) return ;
2026-06-01 22:48:15 +09:00
if ( event . buttons !== undefined ) {
const requiredButton = editorPointer ? . button === 2 ? 2 : 1 ;
if ( ( event . buttons & requiredButton ) === 0 ) return ;
}
2026-06-01 18:27:25 +09:00
event . preventDefault ( ) ;
paintAtClient ( event . clientX , event . clientY , 0 , event . shiftKey ) ;
}
function onPaintPointerUp ( event ) {
if ( editorPointer . pointerId === event . pointerId ) editorPointer . panning = false ;
2026-06-01 22:48:15 +09:00
if ( shapeGesture || selectionGesture ) handleEditorSpecialPointerUp ( event ) ;
2026-06-01 18:27:25 +09:00
isPainting = false ;
lastPaintedKey = '' ;
2026-06-01 22:48:15 +09:00
finishEditorGesture ( ) ;
2026-06-01 18:27:25 +09:00
}
function onPaintClick ( event ) {
if ( event . button !== 0 ) return ;
2026-06-01 22:48:15 +09:00
if ( suppressNextPaintClick ) { suppressNextPaintClick = false ; return ; }
2026-06-01 18:27:25 +09:00
event . preventDefault ( ) ;
lastPaintedKey = '' ;
2026-06-01 22:48:15 +09:00
if ( paintTool === 'line' || paintTool === 'rect' || paintTool === 'select' ) return ;
beginEditorGesture ( ) ;
2026-06-01 18:27:25 +09:00
paintAtClient ( event . clientX , event . clientY , 0 , event . shiftKey ) ;
2026-06-01 22:48:15 +09:00
finishEditorGesture ( ) ;
2026-06-01 18:27:25 +09:00
}
function onPaintWheel ( event ) {
event . preventDefault ( ) ;
const rect = els . paintCanvas . getBoundingClientRect ( ) ;
const scaleX = els . paintCanvas . width / rect . width ;
const scaleY = els . paintCanvas . height / rect . height ;
const sx = ( event . clientX - rect . left ) * scaleX ;
const sy = ( event . clientY - rect . top ) * scaleY ;
const worldX = ( sx - editorView . x ) / editorView . zoom ;
const worldY = ( sy - editorView . y ) / editorView . zoom ;
const nextZoom = clamp ( editorView . zoom * ( event . deltaY > 0 ? 1 / 1.12 : 1.12 ) , 1 , 24 ) ;
editorView . zoom = nextZoom ;
editorView . x = sx - worldX * editorView . zoom ;
editorView . y = sy - worldY * editorView . zoom ;
clampEditorView ( ) ;
drawEditor ( ) ;
}
function onPaintMouseDown ( event ) {
// Fallback for browsers/extensions where pointer events are swallowed.
if ( event . button !== 0 && event . button !== 1 && event . button !== 2 ) return ;
event . preventDefault ( ) ;
2026-06-02 16:26:44 +09:00
if ( paintTool === 'select' && event . button === 2 ) {
clearEditorSelection ( true ) ;
return ;
}
2026-06-01 18:27:25 +09:00
lastPaintedKey = '' ;
2026-06-01 22:48:15 +09:00
const isPan = event . button === 1 || ( event . button === 2 && ! usesRightButtonAsPaint ( ) ) ;
2026-06-01 18:27:25 +09:00
mousePaint = { active : ! isPan , panning : isPan , lastX : event . clientX , lastY : event . clientY , button : event . button } ;
2026-06-01 22:48:15 +09:00
if ( performance . now ( ) < suppressMousePaintUntil ) return ;
if ( mousePaint . active ) {
beginEditorGesture ( ) ;
paintAtClient ( event . clientX , event . clientY , event . button , event . shiftKey ) ;
if ( paintTool === 'fill' || paintTool === 'pick' ) { mousePaint . active = false ; finishEditorGesture ( ) ; }
}
2026-06-01 18:27:25 +09:00
}
function onPaintMouseMove ( event ) {
if ( ! mousePaint . active && ! mousePaint . panning ) return ;
event . preventDefault ( ) ;
if ( mousePaint . panning ) {
const rect = els . paintCanvas . getBoundingClientRect ( ) ;
const sx = els . paintCanvas . width / rect . width ;
const sy = els . paintCanvas . height / rect . height ;
editorView . x += ( event . clientX - mousePaint . lastX ) * sx ;
editorView . y += ( event . clientY - mousePaint . lastY ) * sy ;
mousePaint . lastX = event . clientX ;
mousePaint . lastY = event . clientY ;
clampEditorView ( ) ;
drawEditor ( ) ;
return ;
}
paintAtClient ( event . clientX , event . clientY , mousePaint . button || 0 , event . shiftKey ) ;
}
function onPaintMouseUp ( ) {
mousePaint . active = false ;
mousePaint . panning = false ;
lastPaintedKey = '' ;
2026-06-01 22:48:15 +09:00
finishEditorGesture ( ) ;
2026-06-01 18:27:25 +09:00
}
2026-06-01 21:17:09 +09:00
function paintAtEvent ( event ) {
paintAtClient ( event . clientX , event . clientY , event . button || 0 , event . shiftKey ) ;
}
2026-06-01 18:27:25 +09:00
function paintAtClient ( clientX , clientY , button = 0 , shiftKey = false ) {
const local = clientToPaintLocal ( clientX , clientY ) ;
const x = Math . floor ( local . x ) ;
const y = Math . floor ( local . y ) ;
if ( x < 0 || y < 0 || x >= editorSize || y >= editorSize ) return ;
2026-06-01 22:48:15 +09:00
const key = ` ${ x } , ${ y } , ${ paintTool } , ${ button } , ${ shiftKey } ` ;
if ( key === lastPaintedKey && paintTool !== 'fill' ) return ;
2026-06-01 18:27:25 +09:00
lastPaintedKey = key ;
2026-06-01 22:48:15 +09:00
const point = displayCellToCanonical ( x , y ) ;
const index = point . y * editorSize + point . x ;
let changed = false ;
if ( paintTool === 'pick' ) {
const picked = editorPixels [ index ] ;
if ( picked ) selectPaletteCode ( picked ) ;
setPaintTool ( 'brush' ) ;
drawEditor ( ) ;
return ;
}
if ( paintTool === 'fill' ) {
const displayPixels = getDisplayEditorPixels ( ) ;
const fillColor = shiftKey ? null : selectedColorCode ;
const nextDisplay = EditorActions ? . floodFill
? EditorActions . floodFill ( displayPixels , editorSize , x , y , fillColor )
: fallbackFloodFill ( displayPixels , editorSize , x , y , fillColor ) ;
if ( nextDisplay . changed ) {
2026-06-02 16:26:44 +09:00
editorPixels = nextDisplay . pixels ;
2026-06-01 22:48:15 +09:00
editorLeftPixels = mirrorPixels ( editorPixels , editorSize ) ;
if ( fillColor === null && Array . isArray ( nextDisplay . cells ) ) {
for ( const cell of nextDisplay . cells ) {
const src = displayCellToCanonical ( cell . x , cell . y ) ;
removeLightPixel ( src . x , src . y ) ;
2026-06-02 02:35:22 +09:00
removeParticlePixel ( src . x , src . y ) ;
2026-06-01 22:48:15 +09:00
}
}
changed = true ;
}
} else if ( paintTool === 'brush' ) {
if ( editorPixels [ index ] !== selectedColorCode ) {
editorPixels [ index ] = selectedColorCode ;
changed = true ;
}
2026-06-01 18:27:25 +09:00
} else if ( paintTool === 'erase' ) {
2026-06-02 16:26:44 +09:00
const hadLight = lightPixels . some ( ( p ) => p . x === point . x && p . y === point . y ) ;
const hadParticle = particlePixels . some ( ( p ) => p . x === point . x && p . y === point . y ) ;
const hadDepth = depthPixels [ index ] !== 0 ;
if ( hadLight || hadParticle || hadDepth ) {
removeLightPixel ( point . x , point . y ) ;
removeParticlePixel ( point . x , point . y ) ;
depthPixels [ index ] = 0 ;
changed = true ;
} else if ( editorPixels [ index ] !== null ) {
2026-06-01 22:48:15 +09:00
editorPixels [ index ] = null ;
changed = true ;
}
2026-06-01 18:27:25 +09:00
} else if ( paintTool === 'light' ) {
2026-06-01 22:48:15 +09:00
const before = JSON . stringify ( lightPixels ) ;
if ( shiftKey || button === 2 ) removeLightPixel ( point . x , point . y ) ;
else addLightPixel ( point . x , point . y , selectedColorCode ) ;
changed = JSON . stringify ( lightPixels ) !== before ;
2026-06-01 18:27:25 +09:00
updateSettingsSummary ( ) ;
2026-06-02 02:35:22 +09:00
} else if ( paintTool === 'particle' ) {
const before = JSON . stringify ( particlePixels ) ;
if ( shiftKey || button === 2 ) removeParticlePixel ( point . x , point . y ) ;
2026-06-02 16:26:44 +09:00
else addParticlePixel ( point . x , point . y , selectedColorCode , els . particleDirection ? . value || particleConfig . dir || 'up' ) ;
particleConfig = normalizeParticleConfig ( { enabled : particlePixels . length > 0 , c : selectedColorCode , dir : els . particleDirection ? . value || particleConfig . dir || 'up' } ) ;
2026-06-02 02:35:22 +09:00
changed = JSON . stringify ( particlePixels ) !== before ;
2026-06-02 16:26:44 +09:00
updateParticleUI ( ) ;
2026-06-02 02:35:22 +09:00
updateSettingsSummary ( ) ;
2026-06-01 21:17:09 +09:00
} else if ( paintTool === 'depth' ) {
2026-06-02 16:26:44 +09:00
const next = ( shiftKey || button === 2 ) ? 0 : depthPaintMode ;
2026-06-01 22:48:15 +09:00
if ( depthPixels [ index ] !== next ) {
depthPixels [ index ] = next ;
changed = true ;
}
2026-06-01 18:27:25 +09:00
} else if ( paintTool === 'door' ) {
if ( roleToCategory ( currentRole ( ) ) === 'static' && currentRole ( ) === 'building' ) {
2026-06-01 22:48:15 +09:00
if ( doorPixel . x !== x || doorPixel . y !== y ) {
doorPixel = { x , y } ;
changed = true ;
}
2026-06-01 18:27:25 +09:00
updateSettingsSummary ( ) ;
}
}
2026-06-01 22:48:15 +09:00
if ( changed ) {
editorLeftPixels = mirrorPixels ( editorPixels , editorSize ) ;
markEditorChanged ( ) ;
drawEditor ( ) ;
}
2026-06-01 21:17:09 +09:00
}
function paintEventToCell ( event ) {
const local = paintEventToLocal ( event ) ;
return { x : Math . floor ( local . x ) , y : Math . floor ( local . y ) } ;
}
function paintEventToLocal ( event ) {
return clientToPaintLocal ( event . clientX , event . clientY ) ;
2026-06-01 18:27:25 +09:00
}
function clientToPaintLocal ( clientX , clientY ) {
const rect = els . paintCanvas . getBoundingClientRect ( ) ;
const scaleX = els . paintCanvas . width / rect . width ;
const scaleY = els . paintCanvas . height / rect . height ;
const sx = ( clientX - rect . left ) * scaleX ;
const sy = ( clientY - rect . top ) * scaleY ;
2026-06-01 22:48:15 +09:00
const cell = els . paintCanvas . width / editorSize ;
return {
x : ( sx - editorView . x ) / editorView . zoom / cell ,
y : ( sy - editorView . y ) / editorView . zoom / cell
} ;
}
function snapshotEditorState ( ) {
return {
size : editorSize ,
rightPixels : [ ... editorPixels ] ,
depthPixels : [ ... depthPixels ] ,
lightPixels : lightPixels . map ( ( p ) => ( { ... p } ) ) ,
2026-06-02 02:35:22 +09:00
particlePixels : particlePixels . map ( ( p ) => ( { ... p } ) ) ,
2026-06-02 16:26:44 +09:00
particleConfig : { ... particleConfig } ,
2026-06-01 22:48:15 +09:00
doorPixel : { ... doorPixel } ,
editingSide ,
selectedColorCode ,
editorSelection : editorSelection ? { ... editorSelection } : null
} ;
}
function restoreEditorState ( snapshot ) {
editorSize = snapshot . size || editorSize ;
editorPixels = normalizePixels ( snapshot . rightPixels || [ ] , editorSize ) ;
editorLeftPixels = mirrorPixels ( editorPixels , editorSize ) ;
depthPixels = normalizeDepthPixels ( snapshot . depthPixels || [ ] , editorSize ) ;
lightPixels = Array . isArray ( snapshot . lightPixels ) ? snapshot . lightPixels . map ( ( p ) => ( { ... p } ) ) : [ ] ;
2026-06-02 02:35:22 +09:00
particlePixels = Array . isArray ( snapshot . particlePixels ) ? snapshot . particlePixels . map ( ( p ) => ( { ... p } ) ) : [ ] ;
2026-06-02 16:26:44 +09:00
particleConfig = normalizeParticleConfig ( snapshot . particleConfig || ( particlePixels . length ? { enabled : true , c : particlePixels [ 0 ] . c , dir : particlePixels [ 0 ] . dir || 'up' } : particleConfig ) ) ;
2026-06-01 22:48:15 +09:00
doorPixel = snapshot . doorPixel ? { ... snapshot . doorPixel } : { x : Math . floor ( editorSize / 2 ) , y : editorSize - 1 } ;
editingSide = snapshot . editingSide || editingSide ;
if ( snapshot . selectedColorCode ) selectedColorCode = snapshot . selectedColorCode ;
editorSelection = snapshot . editorSelection ? normalizeSelectionRect ( snapshot . editorSelection . x , snapshot . editorSelection . y , snapshot . editorSelection . x + snapshot . editorSelection . w - 1 , snapshot . editorSelection . y + snapshot . editorSelection . h - 1 ) : null ;
els . assetSize . value = String ( editorSize ) ;
updateSideButtons ( ) ;
renderPalette ( ) ;
drawEditor ( ) ;
updateSettingsSummary ( ) ;
updateEditorHistoryButtons ( ) ;
updateEditorSelectionButtons ( ) ;
}
function beginEditorGesture ( ) {
if ( ! editorGestureSnapshot ) {
editorGestureSnapshot = snapshotEditorState ( ) ;
editorGestureChanged = false ;
}
}
function markEditorChanged ( ) {
editorGestureChanged = true ;
}
function finishEditorGesture ( ) {
if ( ! editorGestureSnapshot ) return ;
if ( editorGestureChanged ) {
editorHistory . push ( editorGestureSnapshot ) ;
if ( editorHistory . length > EDITOR _HISTORY _LIMIT ) editorHistory . shift ( ) ;
editorFuture = [ ] ;
}
editorGestureSnapshot = null ;
editorGestureChanged = false ;
updateEditorHistoryButtons ( ) ;
}
function commitEditorMutation ( mutator ) {
const before = snapshotEditorState ( ) ;
const changed = Boolean ( mutator ( ) ) ;
if ( changed ) {
editorHistory . push ( before ) ;
if ( editorHistory . length > EDITOR _HISTORY _LIMIT ) editorHistory . shift ( ) ;
editorFuture = [ ] ;
editorLeftPixels = mirrorPixels ( editorPixels , editorSize ) ;
drawEditor ( ) ;
updateSettingsSummary ( ) ;
updateEditorHistoryButtons ( ) ;
}
return changed ;
}
function clearEditorHistory ( ) {
editorHistory = [ ] ;
editorFuture = [ ] ;
editorGestureSnapshot = null ;
editorGestureChanged = false ;
updateEditorHistoryButtons ( ) ;
}
function undoEditor ( ) {
if ( ! editorHistory . length ) return ;
editorFuture . push ( snapshotEditorState ( ) ) ;
restoreEditorState ( editorHistory . pop ( ) ) ;
}
function redoEditor ( ) {
if ( ! editorFuture . length ) return ;
editorHistory . push ( snapshotEditorState ( ) ) ;
restoreEditorState ( editorFuture . pop ( ) ) ;
}
function updateEditorHistoryButtons ( ) {
if ( els . undoPaint ) els . undoPaint . disabled = editorHistory . length === 0 ;
if ( els . redoPaint ) els . redoPaint . disabled = editorFuture . length === 0 ;
updateEditorSelectionButtons ( ) ;
}
function onEditorKeyDown ( event ) {
if ( ! els . drawer ? . classList . contains ( 'open' ) ) return ;
const key = event . key . toLowerCase ( ) ;
const activeTag = document . activeElement ? . tagName ? . toLowerCase ( ) ;
if ( activeTag === 'input' || activeTag === 'textarea' || activeTag === 'select' ) return ;
if ( ( event . ctrlKey || event . metaKey ) && key === 'z' ) {
event . preventDefault ( ) ;
if ( event . shiftKey ) redoEditor ( ) ;
else undoEditor ( ) ;
} else if ( ( event . ctrlKey || event . metaKey ) && key === 'y' ) {
event . preventDefault ( ) ;
redoEditor ( ) ;
} else if ( editorSelection && [ 'arrowleft' , 'arrowright' , 'arrowup' , 'arrowdown' ] . includes ( key ) ) {
event . preventDefault ( ) ;
const step = event . shiftKey ? 4 : 1 ;
const delta = { arrowleft : [ - step , 0 ] , arrowright : [ step , 0 ] , arrowup : [ 0 , - step ] , arrowdown : [ 0 , step ] } [ key ] ;
moveSelectionBy ( delta [ 0 ] , delta [ 1 ] ) ;
} else if ( key === 'escape' ) {
2026-06-02 16:26:44 +09:00
clearEditorSelection ( false ) ;
2026-06-01 22:48:15 +09:00
} else if ( ! event . ctrlKey && ! event . metaKey && ! event . altKey ) {
const shortcuts = { b : 'brush' , e : 'erase' , f : 'fill' , i : 'pick' , l : 'line' , r : 'rect' , s : 'select' } ;
if ( shortcuts [ key ] ) { event . preventDefault ( ) ; setPaintTool ( shortcuts [ key ] ) ; }
}
}
function fallbackFloodFill ( sourcePixels , size , x , y , colorCode ) {
const pixels = [ ... sourcePixels ] ;
const target = pixels [ y * size + x ] || null ;
const replacement = colorCode || null ;
if ( target === replacement ) return { pixels , changed : false , count : 0 , cells : [ ] } ;
const stack = [ [ x , y ] ] ;
const cells = [ ] ;
while ( stack . length ) {
const [ cx , cy ] = stack . pop ( ) ;
if ( cx < 0 || cy < 0 || cx >= size || cy >= size ) continue ;
const index = cy * size + cx ;
if ( ( pixels [ index ] || null ) !== target ) continue ;
pixels [ index ] = replacement ;
cells . push ( { x : cx , y : cy } ) ;
stack . push ( [ cx + 1 , cy ] , [ cx - 1 , cy ] , [ cx , cy + 1 ] , [ cx , cy - 1 ] ) ;
}
return { pixels , changed : cells . length > 0 , count : cells . length , cells } ;
}
function handleEditorSpecialPointerDown ( event ) {
const cell = clientToCell ( event . clientX , event . clientY ) ;
if ( ! cell ) return false ;
if ( paintTool === 'line' || paintTool === 'rect' ) {
beginEditorGesture ( ) ;
shapeGesture = {
tool : paintTool ,
startX : cell . x ,
startY : cell . y ,
endX : cell . x ,
endY : cell . y ,
erase : event . button === 2 ,
filled : event . shiftKey
} ;
shapePreview = { ... shapeGesture } ;
drawEditor ( ) ;
return true ;
}
if ( paintTool === 'select' ) {
const inside = editorSelection && pointInSelection ( cell . x , cell . y , editorSelection ) ;
if ( inside ) {
beginEditorGesture ( ) ;
const baseSelection = { ... editorSelection } ;
selectionGesture = {
mode : 'move' ,
startX : cell . x ,
startY : cell . y ,
lastDx : 0 ,
lastDy : 0 ,
baseSelection ,
basePixels : getDisplayEditorPixels ( ) ,
baseDepthPixels : getDisplayDepthPixels ( ) ,
2026-06-02 02:35:22 +09:00
baseLightPixels : lightPixels . map ( ( p ) => ( { ... p } ) ) ,
baseParticlePixels : particlePixels . map ( ( p ) => ( { ... p } ) )
2026-06-01 22:48:15 +09:00
} ;
} else {
selectionGesture = { mode : 'select' , startX : cell . x , startY : cell . y , endX : cell . x , endY : cell . y } ;
editorSelection = normalizeSelectionRect ( cell . x , cell . y , cell . x , cell . y ) ;
updateEditorSelectionButtons ( ) ;
drawEditor ( ) ;
}
return true ;
}
return false ;
}
function handleEditorSpecialPointerMove ( event ) {
const cell = clientToCell ( event . clientX , event . clientY ) ;
if ( ! cell ) return ;
if ( shapeGesture ) {
shapeGesture . endX = cell . x ;
shapeGesture . endY = cell . y ;
shapeGesture . filled = event . shiftKey ;
shapePreview = { ... shapeGesture } ;
drawEditor ( ) ;
return ;
}
if ( selectionGesture ? . mode === 'select' ) {
selectionGesture . endX = cell . x ;
selectionGesture . endY = cell . y ;
editorSelection = normalizeSelectionRect ( selectionGesture . startX , selectionGesture . startY , cell . x , cell . y ) ;
updateEditorSelectionButtons ( ) ;
drawEditor ( ) ;
return ;
}
if ( selectionGesture ? . mode === 'move' ) {
let dx = cell . x - selectionGesture . startX ;
let dy = cell . y - selectionGesture . startY ;
( { dx , dy } = clampSelectionDelta ( selectionGesture . baseSelection , dx , dy ) ) ;
if ( dx === selectionGesture . lastDx && dy === selectionGesture . lastDy ) return ;
selectionGesture . lastDx = dx ;
selectionGesture . lastDy = dy ;
2026-06-02 02:35:22 +09:00
applySelectionMoveFromBase ( selectionGesture . basePixels , selectionGesture . baseDepthPixels , selectionGesture . baseLightPixels , selectionGesture . baseParticlePixels , selectionGesture . baseSelection , dx , dy ) ;
2026-06-01 22:48:15 +09:00
markEditorChanged ( ) ;
drawEditor ( ) ;
}
}
function handleEditorSpecialPointerUp ( event ) {
if ( shapeGesture ) {
const changed = commitShapeGesture ( shapeGesture ) ;
if ( changed ) markEditorChanged ( ) ;
shapeGesture = null ;
shapePreview = null ;
drawEditor ( ) ;
return ;
}
if ( selectionGesture ? . mode === 'select' ) {
const rect = normalizeSelectionRect ( selectionGesture . startX , selectionGesture . startY , selectionGesture . endX , selectionGesture . endY ) ;
editorSelection = rect && rect . w * rect . h > 0 ? rect : null ;
selectionGesture = null ;
drawEditor ( ) ;
updateEditorSelectionButtons ( ) ;
return ;
}
if ( selectionGesture ? . mode === 'move' ) {
selectionGesture = null ;
editorLeftPixels = mirrorPixels ( editorPixels , editorSize ) ;
updateEditorSelectionButtons ( ) ;
return ;
}
}
function clientToCell ( clientX , clientY ) {
const local = clientToPaintLocal ( clientX , clientY ) ;
const x = Math . floor ( local . x ) ;
const y = Math . floor ( local . y ) ;
if ( x < 0 || y < 0 || x >= editorSize || y >= editorSize ) return null ;
return { x , y } ;
}
function normalizeSelectionRect ( x0 , y0 , x1 , y1 ) {
const left = clamp ( Math . min ( x0 , x1 ) , 0 , editorSize - 1 ) ;
const top = clamp ( Math . min ( y0 , y1 ) , 0 , editorSize - 1 ) ;
const right = clamp ( Math . max ( x0 , x1 ) , 0 , editorSize - 1 ) ;
const bottom = clamp ( Math . max ( y0 , y1 ) , 0 , editorSize - 1 ) ;
return { x : left , y : top , w : right - left + 1 , h : bottom - top + 1 } ;
}
function pointInSelection ( x , y , selection ) {
return selection && x >= selection . x && y >= selection . y && x < selection . x + selection . w && y < selection . y + selection . h ;
}
function clampSelectionDelta ( selection , dx , dy ) {
if ( ! selection ) return { dx : 0 , dy : 0 } ;
return {
dx : clamp ( dx , - selection . x , editorSize - ( selection . x + selection . w ) ) ,
dy : clamp ( dy , - selection . y , editorSize - ( selection . y + selection . h ) )
} ;
}
function getDisplayDepthPixels ( ) {
2026-06-02 16:26:44 +09:00
return normalizeDepthPixels ( depthPixels , editorSize ) ;
2026-06-01 22:48:15 +09:00
}
function setDisplayEditorPixels ( pixels ) {
2026-06-02 16:26:44 +09:00
editorPixels = normalizePixels ( pixels , editorSize ) ;
2026-06-01 22:48:15 +09:00
editorLeftPixels = mirrorPixels ( editorPixels , editorSize ) ;
}
function setDisplayDepthPixels ( pixels ) {
2026-06-02 16:26:44 +09:00
depthPixels = normalizeDepthPixels ( pixels , editorSize ) ;
2026-06-01 22:48:15 +09:00
}
function mirrorScalarPixels ( values , size ) {
const source = Array . isArray ( values ) ? values : [ ] ;
const out = Array ( size * size ) . fill ( 0 ) ;
for ( let y = 0 ; y < size ; y ++ ) {
for ( let x = 0 ; x < size ; x ++ ) {
out [ y * size + ( size - 1 - x ) ] = source [ y * size + x ] || 0 ;
}
}
return out ;
}
2026-06-02 02:35:22 +09:00
function applySelectionMoveFromBase ( basePixels , baseDepthPixels , baseLightPixels , baseParticlePixels , selection , dx , dy ) {
2026-06-01 22:48:15 +09:00
const nextPixels = normalizePixels ( basePixels , editorSize ) ;
const nextDepth = normalizeDepthPixels ( baseDepthPixels , editorSize ) ;
const movedPixels = [ ... nextPixels ] ;
const movedDepth = [ ... nextDepth ] ;
for ( let y = selection . y ; y < selection . y + selection . h ; y ++ ) {
for ( let x = selection . x ; x < selection . x + selection . w ; x ++ ) {
const index = y * editorSize + x ;
movedPixels [ index ] = null ;
movedDepth [ index ] = 0 ;
}
}
for ( let y = selection . y ; y < selection . y + selection . h ; y ++ ) {
for ( let x = selection . x ; x < selection . x + selection . w ; x ++ ) {
const from = y * editorSize + x ;
const to = ( y + dy ) * editorSize + ( x + dx ) ;
movedPixels [ to ] = nextPixels [ from ] || null ;
movedDepth [ to ] = nextDepth [ from ] || 0 ;
}
}
setDisplayEditorPixels ( movedPixels ) ;
setDisplayDepthPixels ( movedDepth ) ;
2026-06-02 02:35:22 +09:00
lightPixels = moveDisplayPoints ( baseLightPixels , selection , dx , dy ) ;
particlePixels = moveDisplayPoints ( baseParticlePixels , selection , dx , dy ) ;
2026-06-01 22:48:15 +09:00
editorSelection = { x : selection . x + dx , y : selection . y + dy , w : selection . w , h : selection . h } ;
updateEditorSelectionButtons ( ) ;
}
2026-06-02 02:35:22 +09:00
function moveDisplayPoints ( sourcePoints , selection , dx , dy ) {
2026-06-01 22:48:15 +09:00
const out = [ ] ;
2026-06-02 02:35:22 +09:00
for ( const point of sourcePoints || [ ] ) {
const shown = canonicalCellToDisplay ( point . x , point . y ) ;
2026-06-01 22:48:15 +09:00
if ( ! pointInSelection ( shown . x , shown . y , selection ) ) {
2026-06-02 02:35:22 +09:00
out . push ( { ... point } ) ;
2026-06-01 22:48:15 +09:00
continue ;
}
const nx = shown . x + dx ;
const ny = shown . y + dy ;
if ( nx < 0 || ny < 0 || nx >= editorSize || ny >= editorSize ) continue ;
const canonical = displayCellToCanonical ( nx , ny ) ;
2026-06-02 02:35:22 +09:00
out . push ( { ... point , x : canonical . x , y : canonical . y } ) ;
2026-06-01 22:48:15 +09:00
}
return out ;
}
function moveSelectionBy ( dx , dy ) {
if ( ! editorSelection ) return false ;
const bounded = clampSelectionDelta ( editorSelection , dx , dy ) ;
if ( ! bounded . dx && ! bounded . dy ) return false ;
return commitEditorMutation ( ( ) => {
2026-06-02 02:35:22 +09:00
applySelectionMoveFromBase ( getDisplayEditorPixels ( ) , getDisplayDepthPixels ( ) , lightPixels . map ( ( p ) => ( { ... p } ) ) , particlePixels . map ( ( p ) => ( { ... p } ) ) , editorSelection , bounded . dx , bounded . dy ) ;
2026-06-01 22:48:15 +09:00
return true ;
} ) ;
}
function nudgeEditor ( dx , dy ) {
if ( editorSelection ) moveSelectionBy ( dx , dy ) ;
else shiftEditorContent ( dx , dy ) ;
}
function shiftEditorContent ( dx , dy ) {
return commitEditorMutation ( ( ) => {
const selection = { x : 0 , y : 0 , w : editorSize , h : editorSize } ;
const bounded = clampSelectionDelta ( selection , dx , dy ) ;
if ( ! bounded . dx && ! bounded . dy ) return false ;
2026-06-02 02:35:22 +09:00
applySelectionMoveFromBase ( getDisplayEditorPixels ( ) , getDisplayDepthPixels ( ) , lightPixels . map ( ( p ) => ( { ... p } ) ) , particlePixels . map ( ( p ) => ( { ... p } ) ) , selection , bounded . dx , bounded . dy ) ;
2026-06-01 22:48:15 +09:00
editorSelection = null ;
doorPixel = { x : clamp ( doorPixel . x + bounded . dx , 0 , editorSize - 1 ) , y : clamp ( doorPixel . y + bounded . dy , 0 , editorSize - 1 ) } ;
return true ;
} ) ;
}
function commitShapeGesture ( gesture ) {
const cells = gesture . tool === 'line'
? getLineCells ( gesture . startX , gesture . startY , gesture . endX , gesture . endY )
: getRectCells ( gesture . startX , gesture . startY , gesture . endX , gesture . endY , gesture . filled ) ;
if ( ! cells . length ) return false ;
const pixels = getDisplayEditorPixels ( ) ;
const value = gesture . erase ? null : selectedColorCode ;
let changed = false ;
for ( const cell of cells ) {
const index = cell . y * editorSize + cell . x ;
if ( ( pixels [ index ] || null ) !== value ) {
pixels [ index ] = value ;
changed = true ;
}
if ( gesture . erase ) {
const canonical = displayCellToCanonical ( cell . x , cell . y ) ;
removeLightPixel ( canonical . x , canonical . y ) ;
}
}
if ( ! changed ) return false ;
setDisplayEditorPixels ( pixels ) ;
return true ;
}
function getLineCells ( x0 , y0 , x1 , y1 ) {
const cells = [ ] ;
let dx = Math . abs ( x1 - x0 ) ;
let dy = - Math . abs ( y1 - y0 ) ;
const sx = x0 < x1 ? 1 : - 1 ;
const sy = y0 < y1 ? 1 : - 1 ;
let err = dx + dy ;
let x = x0 ;
let y = y0 ;
while ( true ) {
cells . push ( { x , y } ) ;
if ( x === x1 && y === y1 ) break ;
const e2 = 2 * err ;
if ( e2 >= dy ) { err += dy ; x += sx ; }
if ( e2 <= dx ) { err += dx ; y += sy ; }
}
return cells . filter ( ( cell ) => cell . x >= 0 && cell . y >= 0 && cell . x < editorSize && cell . y < editorSize ) ;
}
function getRectCells ( x0 , y0 , x1 , y1 , filled = false ) {
const rect = normalizeSelectionRect ( x0 , y0 , x1 , y1 ) ;
const cells = [ ] ;
for ( let y = rect . y ; y < rect . y + rect . h ; y ++ ) {
for ( let x = rect . x ; x < rect . x + rect . w ; x ++ ) {
if ( filled || x === rect . x || y === rect . y || x === rect . x + rect . w - 1 || y === rect . y + rect . h - 1 ) cells . push ( { x , y } ) ;
}
}
return cells ;
}
function drawEditorOverlays ( cell ) {
if ( shapePreview ) {
const cells = shapePreview . tool === 'line'
? getLineCells ( shapePreview . startX , shapePreview . startY , shapePreview . endX , shapePreview . endY )
: getRectCells ( shapePreview . startX , shapePreview . startY , shapePreview . endX , shapePreview . endY , shapePreview . filled ) ;
pctx . save ( ) ;
pctx . globalAlpha = shapePreview . erase ? 0.32 : 0.42 ;
pctx . fillStyle = shapePreview . erase ? '#ff6b6b' : colorToHex ( selectedColorCode ) ;
for ( const c of cells ) pctx . fillRect ( c . x * cell , c . y * cell , Math . ceil ( cell ) , Math . ceil ( cell ) ) ;
pctx . restore ( ) ;
}
if ( editorSelection ) {
pctx . save ( ) ;
pctx . strokeStyle = '#ff4fa3' ;
pctx . lineWidth = Math . max ( 2 , 2 / editorView . zoom ) ;
pctx . setLineDash ( [ Math . max ( 3 , 6 / editorView . zoom ) , Math . max ( 3 , 4 / editorView . zoom ) ] ) ;
pctx . strokeRect ( editorSelection . x * cell + 1 / editorView . zoom , editorSelection . y * cell + 1 / editorView . zoom , editorSelection . w * cell - 2 / editorView . zoom , editorSelection . h * cell - 2 / editorView . zoom ) ;
pctx . fillStyle = 'rgba(255, 79, 163, .08)' ;
pctx . fillRect ( editorSelection . x * cell , editorSelection . y * cell , editorSelection . w * cell , editorSelection . h * cell ) ;
pctx . restore ( ) ;
}
}
function updateEditorSelectionButtons ( ) {
const disabled = ! editorSelection ;
if ( els . clearSelection ) els . clearSelection . disabled = disabled ;
}
2026-06-02 16:26:44 +09:00
function clearEditorSelection ( announce = false ) {
const hadSelection = ! ! editorSelection || ! ! selectionGesture ;
editorSelection = null ;
selectionGesture = null ;
drawEditor ( ) ;
updateEditorSelectionButtons ( ) ;
if ( announce && hadSelection ) toast ( 'Selection cleared.' ) ;
}
function normalizeParticleConfig ( config , size = editorSize ) {
const input = config && typeof config === 'object' ? config : { } ;
const dir = [ 'up' , 'down' , 'left' , 'right' ] . includes ( input . dir ) ? input . dir : 'up' ;
const color = PALETTE _BY _CODE [ input . c ] ? input . c : nearestPaletteCode ( '#ffffff' ) ;
return { enabled : Boolean ( input . enabled ) , c : color , dir } ;
}
function particleDirectionLabel ( dir ) {
return ( { up : '↑' , down : '↓' , left : '←' , right : '→' } [ dir ] || '↑' ) ;
}
function particleRangeLabel ( ) {
return ` ${ particlePixels . length } emitter cell ${ particlePixels . length === 1 ? '' : 's' } ` ;
}
function updateParticleUI ( ) {
particleConfig = normalizeParticleConfig ( particleConfig ) ;
if ( els . particleDirection ) els . particleDirection . value = particleConfig . dir ;
if ( els . toolParticle ) els . toolParticle . classList . toggle ( 'active' , paintTool === 'particle' || particlePixels . length > 0 ) ;
if ( els . particleRangeStatus ) els . particleRangeStatus . textContent = ` Particle cells: ${ particlePixels . length } . Shift/right-click clears a cell. ` ;
if ( els . particleClearRange ) els . particleClearRange . disabled = particlePixels . length === 0 ;
}
function enableParticleEffect ( ) {
particleConfig = normalizeParticleConfig ( { enabled : true , c : selectedColorCode , dir : els . particleDirection ? . value || particleConfig . dir || 'up' } ) ;
updateParticleUI ( ) ;
updateSettingsSummary ( ) ;
drawEditor ( ) ;
}
function disableParticleEffect ( ) {
if ( ! particlePixels . length && ! particleConfig . enabled ) return ;
particleConfig = normalizeParticleConfig ( { ... particleConfig , enabled : false } ) ;
updateParticleUI ( ) ;
updateSettingsSummary ( ) ;
drawEditor ( ) ;
toast ( 'Particle tool ready. Shift/right-click a particle cell to clear it.' ) ;
}
function displayRectToCanonicalRect ( rect ) {
return null ;
}
function setParticleRangeFromSelection ( ) {
toast ( 'Particle range is no longer used. Paint particle cells directly.' ) ;
}
function clearParticleRange ( ) {
if ( ! particlePixels . length ) return ;
commitEditorMutation ( ( ) => {
particlePixels = [ ] ;
particleConfig = normalizeParticleConfig ( { ... particleConfig , enabled : false } ) ;
return true ;
} ) ;
toast ( 'Particle cells cleared.' ) ;
}
function getPixelBounds ( pixels , size ) {
const source = normalizePixels ( pixels , size ) ;
let minX = Infinity , minY = Infinity , maxX = - Infinity , maxY = - Infinity ;
for ( let y = 0 ; y < size ; y ++ ) {
for ( let x = 0 ; x < size ; x ++ ) {
if ( ! source [ y * size + x ] ) continue ;
minX = Math . min ( minX , x ) ; minY = Math . min ( minY , y ) ;
maxX = Math . max ( maxX , x ) ; maxY = Math . max ( maxY , y ) ;
}
}
return Number . isFinite ( minX ) ? { x : minX , y : minY , w : maxX - minX + 1 , h : maxY - minY + 1 } : null ;
}
2026-06-01 22:48:15 +09:00
function flipEditorHorizontal ( ) {
commitEditorMutation ( ( ) => {
editorPixels = mirrorPixels ( editorPixels , editorSize ) ;
depthPixels = mirrorScalarPixels ( depthPixels , editorSize ) ;
lightPixels = lightPixels . map ( ( p ) => ( { ... p , x : editorSize - 1 - p . x } ) ) ;
2026-06-02 02:35:22 +09:00
particlePixels = particlePixels . map ( ( p ) => ( { ... p , x : editorSize - 1 - p . x } ) ) ;
2026-06-01 22:48:15 +09:00
doorPixel = { ... doorPixel , x : editorSize - 1 - doorPixel . x } ;
editorSelection = editorSelection ? { x : editorSize - ( editorSelection . x + editorSelection . w ) , y : editorSelection . y , w : editorSelection . w , h : editorSelection . h } : null ;
return true ;
} ) ;
}
function flipEditorVertical ( ) {
commitEditorMutation ( ( ) => {
editorPixels = flipPixelsVertical ( editorPixels , editorSize ) ;
depthPixels = flipScalarPixelsVertical ( depthPixels , editorSize ) ;
lightPixels = lightPixels . map ( ( p ) => ( { ... p , y : editorSize - 1 - p . y } ) ) ;
2026-06-02 02:35:22 +09:00
particlePixels = particlePixels . map ( ( p ) => ( { ... p , y : editorSize - 1 - p . y } ) ) ;
2026-06-01 22:48:15 +09:00
doorPixel = { ... doorPixel , y : editorSize - 1 - doorPixel . y } ;
editorSelection = editorSelection ? { x : editorSelection . x , y : editorSize - ( editorSelection . y + editorSelection . h ) , w : editorSelection . w , h : editorSelection . h } : null ;
return true ;
} ) ;
}
function flipPixelsVertical ( pixels , size ) {
const source = normalizePixels ( pixels , size ) ;
const out = blankPixels ( size ) ;
for ( let y = 0 ; y < size ; y ++ ) {
for ( let x = 0 ; x < size ; x ++ ) out [ ( size - 1 - y ) * size + x ] = source [ y * size + x ] || null ;
}
return out ;
}
function flipScalarPixelsVertical ( values , size ) {
const source = normalizeDepthPixels ( values , size ) ;
const out = Array ( size * size ) . fill ( 0 ) ;
for ( let y = 0 ; y < size ; y ++ ) {
for ( let x = 0 ; x < size ; x ++ ) out [ ( size - 1 - y ) * size + x ] = source [ y * size + x ] || 0 ;
}
return out ;
}
function applyEditorOutline ( ) {
commitEditorMutation ( ( ) => {
const source = [ ... editorPixels ] ;
const out = [ ... editorPixels ] ;
let changed = false ;
for ( let y = 0 ; y < editorSize ; y ++ ) {
for ( let x = 0 ; x < editorSize ; x ++ ) {
const index = y * editorSize + x ;
if ( source [ index ] ) continue ;
const adjacent = [ [ 1 , 0 ] , [ - 1 , 0 ] , [ 0 , 1 ] , [ 0 , - 1 ] ] . some ( ( [ dx , dy ] ) => {
const nx = x + dx ;
const ny = y + dy ;
return nx >= 0 && ny >= 0 && nx < editorSize && ny < editorSize && source [ ny * editorSize + nx ] ;
} ) ;
if ( adjacent ) { out [ index ] = selectedColorCode ; changed = true ; }
}
}
if ( ! changed ) return false ;
editorPixels = out ;
return true ;
} ) ;
}
function exportEditorPng ( ) {
const scale = Math . max ( 8 , Math . floor ( 512 / editorSize ) ) ;
const canvas = document . createElement ( 'canvas' ) ;
canvas . width = editorSize * scale ;
canvas . height = editorSize * scale ;
const c = canvas . getContext ( '2d' ) ;
c . imageSmoothingEnabled = false ;
const pixels = getDisplayEditorPixels ( ) ;
for ( let y = 0 ; y < editorSize ; y ++ ) {
for ( let x = 0 ; x < editorSize ; x ++ ) {
const code = pixels [ y * editorSize + x ] ;
if ( ! code ) continue ;
c . fillStyle = colorToHex ( code ) ;
c . fillRect ( x * scale , y * scale , scale , scale ) ;
}
}
const link = document . createElement ( 'a' ) ;
const rawName = ( els . assetName . value || 'pixel-art' ) . trim ( ) . replace ( /[^a-z0-9_-]+/gi , '-' ) . replace ( /^-+|-+$/g , '' ) || 'pixel-art' ;
link . download = ` ${ rawName } - ${ editorSize } x ${ editorSize } .png ` ;
link . href = canvas . toDataURL ( 'image/png' ) ;
link . click ( ) ;
toast ( 'PNG exported.' ) ;
}
function importEditorPng ( event ) {
const file = event . target . files ? . [ 0 ] ;
event . target . value = '' ;
if ( ! file ) return ;
const img = new Image ( ) ;
img . onload = ( ) => {
const targetSize = chooseImportCanvasSize ( img . naturalWidth || img . width , img . naturalHeight || img . height ) ;
commitEditorMutation ( ( ) => {
const previousSize = editorSize ;
const canvas = document . createElement ( 'canvas' ) ;
canvas . width = targetSize ;
canvas . height = targetSize ;
const c = canvas . getContext ( '2d' , { willReadFrequently : true } ) ;
c . imageSmoothingEnabled = false ;
c . clearRect ( 0 , 0 , targetSize , targetSize ) ;
const fit = fitImageInsideSquare ( img . naturalWidth || img . width , img . naturalHeight || img . height , targetSize ) ;
c . drawImage ( img , fit . dx , fit . dy , fit . dw , fit . dh ) ;
const data = c . getImageData ( 0 , 0 , targetSize , targetSize ) . data ;
const next = blankPixels ( targetSize ) ;
for ( let i = 0 ; i < targetSize * targetSize ; i ++ ) {
const alpha = data [ i * 4 + 3 ] ;
if ( alpha < 32 ) continue ;
const hex = rgbToHex ( data [ i * 4 ] , data [ i * 4 + 1 ] , data [ i * 4 + 2 ] ) ;
next [ i ] = nearestPaletteCode ( hex ) ;
}
editorSize = targetSize ;
els . assetSize . value = String ( targetSize ) ;
setDisplayEditorPixels ( next ) ;
depthPixels = blankPixels ( targetSize ) . map ( ( ) => 0 ) ;
lightPixels = [ ] ;
doorPixel = { x : Math . min ( doorPixel . x , targetSize - 1 ) , y : Math . min ( doorPixel . y , targetSize - 1 ) } ;
editorSelection = null ;
if ( previousSize !== targetSize ) resetEditorView ( ) ;
return true ;
} ) ;
URL . revokeObjectURL ( img . src ) ;
const note = ( img . naturalWidth === targetSize && img . naturalHeight === targetSize ) ? '' : ` (fit from ${ img . naturalWidth } × ${ img . naturalHeight } ) ` ;
toast ( ` PNG imported as ${ targetSize } × ${ targetSize } ${ note } . ` ) ;
} ;
img . onerror = ( ) => toast ( 'Could not read that PNG.' ) ;
img . src = URL . createObjectURL ( file ) ;
}
function chooseImportCanvasSize ( width , height ) {
const allowed = [ 8 , 16 , 32 , 64 ] ;
const w = Math . max ( 1 , Math . round ( Number ( width ) || 1 ) ) ;
const h = Math . max ( 1 , Math . round ( Number ( height ) || 1 ) ) ;
if ( w === h && allowed . includes ( w ) ) return w ;
const target = Math . max ( w , h ) ;
return allowed . find ( ( size ) => target <= size ) || allowed [ allowed . length - 1 ] ;
}
function fitImageInsideSquare ( width , height , size ) {
const w = Math . max ( 1 , Number ( width ) || 1 ) ;
const h = Math . max ( 1 , Number ( height ) || 1 ) ;
const scale = Math . min ( size / w , size / h ) ;
const dw = Math . max ( 1 , Math . round ( w * scale ) ) ;
const dh = Math . max ( 1 , Math . round ( h * scale ) ) ;
2026-06-01 18:27:25 +09:00
return {
2026-06-01 22:48:15 +09:00
dx : Math . floor ( ( size - dw ) / 2 ) ,
dy : Math . floor ( ( size - dh ) / 2 ) ,
dw ,
dh
2026-06-01 18:27:25 +09:00
} ;
}
2026-06-01 22:48:15 +09:00
function rgbToHex ( r , g , b ) {
const part = ( value ) => clamp ( Math . round ( value ) , 0 , 255 ) . toString ( 16 ) . padStart ( 2 , '0' ) ;
return ` # ${ part ( r ) } ${ part ( g ) } ${ part ( b ) } ` ;
}
function selectPaletteCode ( code ) {
if ( ! PALETTE _BY _CODE [ code ] ) return ;
selectedColorCode = code ;
els . paintColor . value = PALETTE _BY _CODE [ code ] ;
2026-06-02 16:26:44 +09:00
if ( paintTool === 'particle' ) particleConfig = normalizeParticleConfig ( { ... particleConfig , c : code } ) ;
2026-06-01 22:48:15 +09:00
renderPalette ( ) ;
2026-06-02 16:26:44 +09:00
updateParticleUI ( ) ;
updateSettingsSummary ( ) ;
2026-06-01 22:48:15 +09:00
}
2026-06-01 18:27:25 +09:00
function getActiveEditorPixels ( ) {
2026-06-01 22:48:15 +09:00
return editorPixels ;
2026-06-01 18:27:25 +09:00
}
2026-06-01 22:48:15 +09:00
function getDisplayEditorPixels ( ) {
2026-06-02 16:26:44 +09:00
return normalizePixels ( editorPixels , editorSize ) ;
2026-06-01 22:48:15 +09:00
}
function displayCellToCanonical ( x , y ) {
return { x , y } ;
}
function canonicalCellToDisplay ( x , y ) {
return { x , y } ;
}
function setupEditor ( size , rightPixels , leftPixels = null , nextDepthPixels = null ) {
2026-06-01 18:27:25 +09:00
editorSize = size ;
2026-06-01 22:48:15 +09:00
const normalizedRight = normalizePixels ( rightPixels , size ) ;
const normalizedLeft = leftPixels ? normalizePixels ( leftPixels , size ) : null ;
const canonical = hasAnyPixel ( normalizedRight ) || ! normalizedLeft ? normalizedRight : mirrorPixels ( normalizedLeft , size ) ;
editorPixels = normalizePixels ( canonical , size ) ;
editorLeftPixels = mirrorPixels ( editorPixels , size ) ;
editorSelection = null ;
depthPixels = Array . isArray ( nextDepthPixels ) || typeof nextDepthPixels === 'string' ? normalizeDepthPixels ( nextDepthPixels , size ) : resizeDepthPixels ( depthPixels , Math . sqrt ( depthPixels . length ) || size , size ) ;
2026-06-01 18:27:25 +09:00
els . assetSize . value = String ( size ) ;
lightPixels = lightPixels . filter ( ( p ) => p . x >= 0 && p . y >= 0 && p . x < size && p . y < size ) ;
2026-06-02 02:35:22 +09:00
particlePixels = particlePixels . filter ( ( p ) => p . x >= 0 && p . y >= 0 && p . x < size && p . y < size ) ;
2026-06-02 16:26:44 +09:00
particleConfig = normalizeParticleConfig ( particleConfig ) ;
2026-06-01 18:27:25 +09:00
doorPixel = { x : clamp ( doorPixel . x , 0 , size - 1 ) , y : clamp ( doorPixel . y , 0 , size - 1 ) } ;
resetEditorView ( ) ;
drawEditor ( ) ;
2026-06-01 22:48:15 +09:00
updateEditorHistoryButtons ( ) ;
2026-06-01 18:27:25 +09:00
}
function drawEditor ( ) {
clampEditorView ( ) ;
const canvas = els . paintCanvas ;
const rectSize = canvas . width ;
const cell = rectSize / editorSize ;
pctx . clearRect ( 0 , 0 , rectSize , rectSize ) ;
pctx . fillStyle = '#fffaf0' ;
pctx . fillRect ( 0 , 0 , rectSize , rectSize ) ;
pctx . save ( ) ;
pctx . translate ( editorView . x , editorView . y ) ;
pctx . scale ( editorView . zoom , editorView . zoom ) ;
2026-06-01 22:48:15 +09:00
const pixels = getDisplayEditorPixels ( ) ;
2026-06-01 18:27:25 +09:00
for ( let y = 0 ; y < editorSize ; y ++ ) {
for ( let x = 0 ; x < editorSize ; x ++ ) {
const color = pixels [ y * editorSize + x ] ;
if ( ! color ) continue ;
pctx . fillStyle = colorToHex ( color ) ;
pctx . fillRect ( x * cell , y * cell , Math . ceil ( cell ) , Math . ceil ( cell ) ) ;
}
}
2026-06-01 21:17:09 +09:00
if ( advancedDraw && depthPixels ? . length ) {
if ( paintTool === 'depth' ) {
pctx . fillStyle = 'rgba(46, 89, 160, .07)' ;
pctx . fillRect ( 0 , 0 , rectSize , rectSize ) ;
2026-06-01 18:27:25 +09:00
}
for ( let y = 0 ; y < editorSize ; y ++ ) {
for ( let x = 0 ; x < editorSize ; x ++ ) {
2026-06-01 22:48:15 +09:00
const source = displayCellToCanonical ( x , y ) ;
const depth = depthPixels [ source . y * editorSize + source . x ] || 0 ;
2026-06-01 21:17:09 +09:00
if ( ! depth ) continue ;
2026-06-01 22:48:15 +09:00
const high = depth > 0 ;
pctx . fillStyle = high ? 'rgba(34, 116, 255, .62)' : 'rgba(88, 44, 120, .62)' ;
2026-06-01 21:17:09 +09:00
pctx . fillRect ( x * cell + Math . max ( 1 , cell * . 12 ) , y * cell + Math . max ( 1 , cell * . 12 ) , Math . max ( 2 , cell * . 76 ) , Math . max ( 2 , cell * . 76 ) ) ;
2026-06-01 22:48:15 +09:00
pctx . strokeStyle = high ? 'rgba(6, 28, 75, .55)' : 'rgba(35, 13, 54, .55)' ;
2026-06-01 21:17:09 +09:00
pctx . lineWidth = Math . max ( 1 , 2 / editorView . zoom ) ;
pctx . strokeRect ( x * cell + Math . max ( 1 , cell * . 12 ) , y * cell + Math . max ( 1 , cell * . 12 ) , Math . max ( 2 , cell * . 76 ) , Math . max ( 2 , cell * . 76 ) ) ;
2026-06-01 18:27:25 +09:00
}
}
}
pctx . strokeStyle = 'rgba(36, 48, 68, .13)' ;
pctx . lineWidth = 1 / editorView . zoom ;
for ( let i = 0 ; i <= editorSize ; i ++ ) {
const p = Math . round ( i * cell ) + . 5 ;
pctx . beginPath ( ) ; pctx . moveTo ( p , 0 ) ; pctx . lineTo ( p , rectSize ) ; pctx . stroke ( ) ;
pctx . beginPath ( ) ; pctx . moveTo ( 0 , p ) ; pctx . lineTo ( rectSize , p ) ; pctx . stroke ( ) ;
}
{
for ( const light of lightPixels ) {
2026-06-01 22:48:15 +09:00
const shown = canonicalCellToDisplay ( light . x , light . y ) ;
const lx = ( shown . x + . 5 ) * cell ;
const ly = ( shown . y + . 5 ) * cell ;
2026-06-01 18:27:25 +09:00
pctx . fillStyle = hexToRgba ( colorToHex ( light . c || selectedColorCode ) , . 28 ) ;
pctx . beginPath ( ) ;
pctx . arc ( lx , ly , Math . max ( 4 , cell * . 20 ) , 0 , Math . PI * 2 ) ;
pctx . fill ( ) ;
pctx . strokeStyle = colorToHex ( light . c || selectedColorCode ) ;
pctx . lineWidth = 2 / editorView . zoom ;
pctx . beginPath ( ) ;
pctx . arc ( lx , ly , Math . max ( 3 , cell * . 14 ) , 0 , Math . PI * 2 ) ;
pctx . stroke ( ) ;
}
2026-06-02 02:35:22 +09:00
for ( const emitter of particlePixels ) {
const shown = canonicalCellToDisplay ( emitter . x , emitter . y ) ;
const lx = ( shown . x + . 5 ) * cell ;
const ly = ( shown . y + . 5 ) * cell ;
const color = colorToHex ( emitter . c || selectedColorCode ) ;
2026-06-02 16:26:44 +09:00
pctx . fillStyle = hexToRgba ( color , . 20 ) ;
2026-06-02 02:35:22 +09:00
pctx . fillRect ( lx - Math . max ( 3 , cell * . 18 ) , ly - Math . max ( 3 , cell * . 18 ) , Math . max ( 6 , cell * . 36 ) , Math . max ( 6 , cell * . 36 ) ) ;
pctx . strokeStyle = color ;
pctx . lineWidth = 2 / editorView . zoom ;
pctx . strokeRect ( lx - Math . max ( 2 , cell * . 12 ) , ly - Math . max ( 2 , cell * . 12 ) , Math . max ( 4 , cell * . 24 ) , Math . max ( 4 , cell * . 24 ) ) ;
}
2026-06-01 18:27:25 +09:00
if ( currentRole ( ) === 'building' ) {
pctx . strokeStyle = '#5fb8ff' ;
pctx . lineWidth = 3 / editorView . zoom ;
pctx . strokeRect ( doorPixel . x * cell + 2 / editorView . zoom , doorPixel . y * cell + 2 / editorView . zoom , cell - 4 / editorView . zoom , cell - 4 / editorView . zoom ) ;
}
}
2026-06-01 22:48:15 +09:00
drawEditorOverlays ( cell ) ;
2026-06-01 18:27:25 +09:00
pctx . restore ( ) ;
}
function resetEditorView ( ) {
editorView . zoom = 1 ;
editorView . x = 0 ;
editorView . y = 0 ;
clampEditorView ( ) ;
}
function clampEditorView ( ) {
const size = els . paintCanvas . width ;
const scaled = size * editorView . zoom ;
const minOffset = size - scaled ;
editorView . x = clamp ( editorView . x , minOffset , 0 ) ;
editorView . y = clamp ( editorView . y , minOffset , 0 ) ;
}
function addLightPixel ( x , y , colorCode = selectedColorCode ) {
const point = { x : clamp ( Math . floor ( Number ( x ) ) , 0 , editorSize - 1 ) , y : clamp ( Math . floor ( Number ( y ) ) , 0 , editorSize - 1 ) , c : colorCode } ;
const existing = lightPixels . find ( ( p ) => p . x === point . x && p . y === point . y ) ;
if ( existing ) existing . c = colorCode ;
else lightPixels . push ( point ) ;
}
function removeLightPixel ( x , y ) {
lightPixels = lightPixels . filter ( ( p ) => ! ( p . x === x && p . y === y ) ) ;
}
2026-06-02 16:26:44 +09:00
function addParticlePixel ( x , y , colorCode = selectedColorCode , dir = particleConfig . dir || 'up' ) {
const point = { x : clamp ( Math . floor ( Number ( x ) ) , 0 , editorSize - 1 ) , y : clamp ( Math . floor ( Number ( y ) ) , 0 , editorSize - 1 ) , c : colorCode , dir : [ 'up' , 'down' , 'left' , 'right' ] . includes ( dir ) ? dir : 'up' } ;
2026-06-02 02:35:22 +09:00
const existing = particlePixels . find ( ( p ) => p . x === point . x && p . y === point . y ) ;
2026-06-02 16:26:44 +09:00
if ( existing ) { existing . c = colorCode ; existing . dir = point . dir ; }
2026-06-02 02:35:22 +09:00
else particlePixels . push ( point ) ;
}
function removeParticlePixel ( x , y ) {
particlePixels = particlePixels . filter ( ( p ) => ! ( p . x === x && p . y === y ) ) ;
}
2026-06-01 18:27:25 +09:00
2026-06-02 16:26:44 +09:00
function mirrorLightPointsHorizontal ( points , size ) {
return ( Array . isArray ( points ) ? points : [ ] )
. map ( ( p ) => ( { ... p , x : size - 1 - clampInt ( p . x , 0 , size - 1 , 0 ) } ) )
. filter ( ( p ) => p . x >= 0 && p . x < size && p . y >= 0 && p . y < size ) ;
}
function mirrorParticlePointsHorizontal ( points , size ) {
return ( Array . isArray ( points ) ? points : [ ] )
. map ( ( p ) => ( { ... p , x : size - 1 - clampInt ( p . x , 0 , size - 1 , 0 ) , dir : p . dir === 'left' ? 'right' : p . dir === 'right' ? 'left' : ( p . dir || 'up' ) } ) )
. filter ( ( p ) => p . x >= 0 && p . x < size && p . y >= 0 && p . y < size ) ;
}
function shiftParticlePointsVertical ( points , size , shift = 0 ) {
return ( Array . isArray ( points ) ? points : [ ] )
. map ( ( p ) => ( { ... p , y : clampInt ( p . y , 0 , size - 1 , 0 ) + shift } ) )
. filter ( ( p ) => p . x >= 0 && p . x < size && p . y >= 0 && p . y < size ) ;
}
function normalizeEditorOrientationForSave ( size ) {
const dynamicLeftCanvas = roleToCategory ( currentRole ( ) ) === 'dynamic' && editingSide === 'left' ;
const rightPixels = dynamicLeftCanvas ? mirrorPixels ( editorPixels , size ) : normalizePixels ( editorPixels , size ) ;
const normalizedDepth = normalizeDepthPixels ( depthPixels , size ) ;
const orientedDepth = dynamicLeftCanvas ? mirrorDepthPixels ( normalizedDepth , size ) : normalizedDepth ;
const orientedLights = dynamicLeftCanvas ? mirrorLightPointsHorizontal ( lightPixels , size ) : lightPixels . map ( ( p ) => ( { ... p } ) ) ;
const orientedParticles = dynamicLeftCanvas ? mirrorParticlePointsHorizontal ( particlePixels , size ) : particlePixels . map ( ( p ) => ( { ... p } ) ) ;
const orientedParticleConfig = normalizeParticleConfig ( { ... particleConfig , enabled : orientedParticles . length > 0 } , size ) ;
return { rightPixels , depthPixels : orientedDepth , lightPixels : orientedLights , particlePixels : orientedParticles , particleConfig : orientedParticleConfig , mirroredFromLeft : dynamicLeftCanvas } ;
}
2026-06-01 21:17:09 +09:00
function alignEditorStateToBottom ( size ) {
2026-06-02 16:26:44 +09:00
const oriented = normalizeEditorOrientationForSave ( size ) ;
const shift = getBottomShift ( oriented . rightPixels , size ) ;
2026-06-01 18:27:25 +09:00
return {
2026-06-02 16:26:44 +09:00
rightPixels : shiftPixelsVertical ( oriented . rightPixels , size , shift ) ,
depthPixels : shiftDepthPixelsVertical ( oriented . depthPixels , size , shift ) ,
lightPixels : oriented . lightPixels
2026-06-02 02:35:22 +09:00
. map ( ( p ) => ( { ... p , y : p . y + shift } ) )
. filter ( ( p ) => p . x >= 0 && p . x < size && p . y >= 0 && p . y < size ) ,
2026-06-02 16:26:44 +09:00
particlePixels : shiftParticlePointsVertical ( oriented . particlePixels , size , shift ) ,
particleConfig : normalizeParticleConfig ( { ... oriented . particleConfig , enabled : oriented . particlePixels . length > 0 } , size ) ,
door : { x : doorPixel . x , y : clamp ( doorPixel . y + shift , 0 , size - 1 ) } ,
mirroredFromLeft : oriented . mirroredFromLeft
2026-06-01 18:27:25 +09:00
} ;
}
2026-06-01 21:17:09 +09:00
function getBottomShift ( pixels , size ) {
let maxY = - 1 ;
for ( let y = 0 ; y < size ; y ++ ) {
for ( let x = 0 ; x < size ; x ++ ) {
if ( pixels [ y * size + x ] ) maxY = Math . max ( maxY , y ) ;
}
2026-06-01 18:27:25 +09:00
}
2026-06-01 21:17:09 +09:00
return maxY < 0 ? 0 : size - 1 - maxY ;
2026-06-01 18:27:25 +09:00
}
2026-06-01 21:17:09 +09:00
function shiftPixelsVertical ( pixels , size , shift ) {
if ( ! shift ) return normalizePixels ( pixels , size ) ;
const out = blankPixels ( size ) ;
const source = normalizePixels ( pixels , size ) ;
for ( let y = 0 ; y < size ; y ++ ) {
for ( let x = 0 ; x < size ; x ++ ) {
const ny = y + shift ;
if ( ny >= 0 && ny < size ) out [ ny * size + x ] = source [ y * size + x ] || null ;
}
}
return out ;
2026-06-01 18:27:25 +09:00
}
2026-06-01 21:17:09 +09:00
function shiftDepthPixelsVertical ( pixels , size , shift ) {
const source = normalizeDepthPixels ( pixels , size ) ;
if ( ! shift ) return source ;
const out = Array ( size * size ) . fill ( 0 ) ;
for ( let y = 0 ; y < size ; y ++ ) {
for ( let x = 0 ; x < size ; x ++ ) {
const ny = y + shift ;
if ( ny >= 0 && ny < size ) out [ ny * size + x ] = source [ y * size + x ] || 0 ;
}
}
return out ;
2026-06-01 18:27:25 +09:00
}
function saveAssetFromEditor ( ) {
const role = currentRole ( ) ;
const category = roleToCategory ( role ) ;
const subtype = roleToSubtype ( role ) ;
const size = editorSize ;
2026-06-02 02:35:22 +09:00
const existing = null ;
2026-06-01 18:27:25 +09:00
const paintedDots = countPixels ( editorPixels ) ;
if ( paintedDots < 10 ) {
toast ( 'Draw at least 10 pixels before saving.' ) ;
return null ;
}
2026-06-02 00:05:03 +09:00
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 ;
}
2026-06-01 18:27:25 +09:00
const name = ( els . assetName . value || '' ) . trim ( ) || existing ? . name || ` ${ cap ( role ) } ${ state . assets . length + 1 } ` ;
2026-06-01 21:17:09 +09:00
const aligned = alignEditorStateToBottom ( size ) ;
2026-06-01 18:27:25 +09:00
const asset = {
id : existing ? . id || uid ( ) ,
name ,
category ,
subtype ,
size ,
createdAt : existing ? . createdAt || Date . now ( ) ,
updatedAt : Date . now ( ) ,
author : existing ? . author || state . authorName || 'Local Artist' ,
parentAssetId : existing ? existing . parentAssetId : editParentId ,
originalAssetId : existing ? existing . originalAssetId : editOriginalId ,
2026-06-01 21:17:09 +09:00
pixels : encodePixels ( aligned . rightPixels ) ,
2026-06-01 18:27:25 +09:00
faces : category === 'dynamic' ? {
2026-06-01 21:17:09 +09:00
right : encodePixels ( aligned . rightPixels ) ,
2026-06-01 22:48:15 +09:00
left : 'mirror'
2026-06-01 18:27:25 +09:00
} : null ,
2026-06-02 02:35:22 +09:00
meta : buildAssetMeta ( category , subtype , aligned . depthPixels , aligned . lightPixels , selectedColorCode , aligned . door , size , aligned . particlePixels )
2026-06-01 18:27:25 +09:00
} ;
2026-06-01 22:48:15 +09:00
asset . contentHash = computeAssetContentHash ( asset ) ;
2026-06-01 18:27:25 +09:00
if ( existing ) {
const index = state . assets . findIndex ( ( a ) => a . id === existing . id ) ;
if ( index >= 0 ) state . assets [ index ] = asset ;
toast ( ` ${ asset . name } updated. ` ) ;
} else {
state . assets . unshift ( asset ) ;
2026-06-02 16:26:44 +09:00
toast ( aligned . mirroredFromLeft ? ` ${ asset . name } saved. Left-facing canvas was mirrored into right-facing movement. ` : ` ${ asset . name } saved. ` ) ;
2026-06-01 18:27:25 +09:00
}
selectedAssetId = asset . id ;
2026-06-01 22:48:15 +09:00
recordSyncEvent ( Phase2Sync ? . createAssetUpsertEvent ? . ( asset ) ) ;
2026-06-01 18:27:25 +09:00
editParentId = null ;
editOriginalId = null ;
editingAssetId = null ;
saveState ( ) ;
2026-06-01 21:17:09 +09:00
spriteCache . clear ( ) ;
2026-06-01 18:27:25 +09:00
hydrateRuntime ( ) ;
renderLibrary ( ) ;
updateSelectedLabel ( ) ;
2026-06-02 16:26:44 +09:00
els . lineageNote . textContent = 'Saved as a permanent collection work. Island placement is a separate temporary exhibition object.' ;
2026-06-01 18:27:25 +09:00
return asset ;
}
function saveAndPlaceFromEditor ( ) {
2026-06-02 16:26:44 +09:00
ensureLocalAccount ( 'save-place' ) ;
2026-06-01 18:27:25 +09:00
const asset = saveAssetFromEditor ( ) ;
if ( ! asset ) return ;
selectedAssetId = asset . id ;
2026-06-02 16:26:44 +09:00
placementPreview = { asset , x : null , y : null , savedAssetId : asset . id } ;
clearWorldSelection ( false ) ;
2026-06-01 18:27:25 +09:00
setMode ( 'place' ) ;
setDrawerOpen ( false ) ;
2026-06-02 16:26:44 +09:00
if ( els . placementPreviewBar ) els . placementPreviewBar . hidden = false ;
if ( els . openEditor ) els . openEditor . hidden = true ;
toast ( 'Saved to Collection. Click a tile, then choose Place here or Back to canvas.' ) ;
2026-06-02 02:35:22 +09:00
}
function buildEditorAssetPreview ( ) {
const role = currentRole ( ) ;
const category = roleToCategory ( role ) ;
const subtype = roleToSubtype ( role ) ;
const size = editorSize ;
if ( countPixels ( editorPixels ) < 10 ) {
toast ( 'Draw at least 10 pixels before checking on the island.' ) ;
return null ;
}
const aligned = alignEditorStateToBottom ( size ) ;
const asset = {
id : ` preview: ${ Date . now ( ) } ` ,
name : ( els . assetName . value || '' ) . trim ( ) || 'Preview work' ,
category , subtype , size , author : state . authorName || 'Local Artist' ,
pixels : encodePixels ( aligned . rightPixels ) ,
faces : category === 'dynamic' ? { right : encodePixels ( aligned . rightPixels ) , left : 'mirror' } : null ,
meta : buildAssetMeta ( category , subtype , aligned . depthPixels , aligned . lightPixels , selectedColorCode , aligned . door , size , aligned . particlePixels )
} ;
return asset ;
}
function checkCurrentEditorOnIsland ( ) {
const asset = buildEditorAssetPreview ( ) ;
if ( ! asset ) return ;
placementPreview = { asset , x : null , y : null } ;
setDrawerOpen ( false ) ;
setMode ( 'place' ) ;
if ( els . placementPreviewBar ) els . placementPreviewBar . hidden = false ;
2026-06-02 16:26:44 +09:00
if ( els . openEditor ) els . openEditor . hidden = true ;
2026-06-02 02:35:22 +09:00
toast ( 'Click a valid tile to preview. Then choose Place here or Back to canvas.' ) ;
}
2026-06-02 16:26:44 +09:00
function cancelPlacementPreview ( reopenDrawer = true ) {
const hadPreview = ! ! placementPreview ;
2026-06-02 02:35:22 +09:00
placementPreview = null ;
if ( els . placementPreviewBar ) els . placementPreviewBar . hidden = true ;
2026-06-02 16:26:44 +09:00
if ( ! reopenDrawer && els . openEditor ) els . openEditor . hidden = false ;
if ( reopenDrawer ) setDrawerOpen ( true ) ;
if ( hadPreview ) render ( ) ;
2026-06-02 02:35:22 +09:00
}
function confirmPreviewPlacement ( ) {
if ( ! placementPreview ? . asset || placementPreview . x == null || placementPreview . y == null ) {
toast ( 'Click a tile first.' ) ;
return ;
}
2026-06-02 16:26:44 +09:00
const assetId = placementPreview . savedAssetId || placementPreview . asset . id ;
if ( ! findAsset ( assetId ) ) {
toast ( 'Saved asset is missing. Back to canvas and save again.' ) ;
return ;
}
selectedAssetId = assetId ;
2026-06-02 02:35:22 +09:00
const { x , y } = placementPreview ;
placementPreview = null ;
if ( els . placementPreviewBar ) els . placementPreviewBar . hidden = true ;
2026-06-02 16:26:44 +09:00
if ( els . openEditor ) els . openEditor . hidden = false ;
2026-06-02 02:35:22 +09:00
placeSelected ( x , y ) ;
2026-06-01 18:27:25 +09:00
}
function newAsset ( ) {
editParentId = null ;
editOriginalId = null ;
els . assetName . value = '' ;
els . assetCategory . value = 'nature' ;
staticKind = 'nature' ;
dynamicKind = 'human' ;
editingSide = 'right' ;
lightPixels = [ ] ;
2026-06-02 02:35:22 +09:00
particlePixels = [ ] ;
2026-06-02 16:26:44 +09:00
particleConfig = { enabled : false , c : selectedColorCode , dir : 'up' } ;
2026-06-01 21:17:09 +09:00
depthPixels = blankPixels ( 8 ) . map ( ( ) => 0 ) ;
2026-06-01 18:27:25 +09:00
doorPixel = { x : Math . floor ( editorSize / 2 ) , y : editorSize - 1 } ;
2026-06-01 22:48:15 +09:00
setupEditor ( 8 , blankPixels ( 8 ) , null ) ;
clearEditorHistory ( ) ;
2026-06-01 18:27:25 +09:00
refreshCategoryUI ( ) ;
els . lineageNote . textContent = '' ;
}
function hydrateAuthorUI ( ) {
2026-06-02 16:26:44 +09:00
state . account = normalizeAccount ( state . account ) ;
state . authorName = state . account ? . name || state . authorName || 'Local Artist' ;
2026-06-01 18:27:25 +09:00
if ( els . authorName ) els . authorName . value = state . authorName ;
2026-06-02 16:26:44 +09:00
updateAccountUI ( ) ;
2026-06-01 18:27:25 +09:00
}
2026-06-02 16:26:44 +09:00
function normalizeAccount ( account ) {
if ( ! account ? . createdAt ) return null ;
const id = String ( account . id || ` px- ${ uid ( ) } ` ) . replace ( /^local:/ , 'px-' ) ;
const password = String ( account . password || account . pass || generateLocalPassword ( ) ) ;
const name = String ( account . name || id ) . trim ( ) || id ;
return { id , name , password , createdAt : Number ( account . createdAt ) || Date . now ( ) } ;
}
function generateAccountId ( ) {
return ` px- ${ uid ( ) . replace ( /[^a-z0-9]/gi , '' ) . slice ( 0 , 10 ) } ` ;
}
function generateLocalPassword ( ) {
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789' ;
if ( globalThis . crypto ? . getRandomValues ) {
const bytes = new Uint8Array ( 12 ) ;
globalThis . crypto . getRandomValues ( bytes ) ;
return [ ... bytes ] . map ( ( b ) => alphabet [ b % alphabet . length ] ) . join ( '' ) ;
}
return Array . from ( { length : 12 } , ( ) => alphabet [ Math . floor ( Math . random ( ) * alphabet . length ) ] ) . join ( '' ) ;
}
function createLocalAccount ( silent = false ) {
if ( ! state . account ? . createdAt ) {
const id = generateAccountId ( ) ;
state . account = { id , name : id , password : generateLocalPassword ( ) , createdAt : Date . now ( ) } ;
state . authorName = id ;
} else {
const name = ( els . authorName ? . value || state . account . name || state . account . id ) . trim ( ) || state . account . id ;
state . account = normalizeAccount ( { ... state . account , name , password : ( els . accountPass ? . value || state . account . password || '' ) . trim ( ) || generateLocalPassword ( ) } ) ;
state . authorName = state . account . name ;
2026-06-02 02:35:22 +09:00
}
saveState ( ) ;
updateAccountUI ( ) ;
2026-06-02 16:26:44 +09:00
renderLibrary ( ) ;
if ( ! silent ) toast ( 'Local account generated. Change the password after creation.' ) ;
return state . account ;
}
function ensureLocalAccount ( reason = 'publish' ) {
if ( state . account ? . createdAt ) {
state . account = normalizeAccount ( state . account ) ;
state . authorName = state . account . name ;
updateAccountUI ( ) ;
return state . account ;
}
const account = createLocalAccount ( true ) ;
toast ( reason === 'save-place' ? 'Local account auto-generated. You can rename it and should change the password.' : 'Local account auto-generated for publishing. Change the password after creation.' ) ;
return account ;
2026-06-02 02:35:22 +09:00
}
function updateAccountUI ( ) {
if ( ! els . accountNote ) return ;
if ( ! state . account ? . createdAt ) {
2026-06-02 16:26:44 +09:00
if ( els . accountId ) els . accountId . value = '' ;
if ( els . authorName ) els . authorName . value = state . authorName || 'Local Artist' ;
if ( els . accountPass ) els . accountPass . value = '' ;
els . accountNote . textContent = 'Save + Place creates a local ID, name, and password. Change the password after creation.' ;
if ( els . createAccount ) els . createAccount . hidden = false ;
2026-06-02 02:35:22 +09:00
return ;
}
2026-06-02 16:26:44 +09:00
state . account = normalizeAccount ( state . account ) ;
state . authorName = state . account . name ;
if ( els . accountId ) els . accountId . value = state . account . id ;
if ( els . authorName ) els . authorName . value = state . account . name ;
if ( els . accountPass ) els . accountPass . value = state . account . password || '' ;
2026-06-02 02:35:22 +09:00
const quota = getPublishQuotaStatus ( ) ;
const day = getAccountAgeMs ( ) < 24 * 60 * 60 * 1000 ? 'first day' : 'day 2+' ;
2026-06-02 16:26:44 +09:00
els . accountNote . textContent = ` Account ${ day } ; publish ${ quota . used } / ${ quota . limit } this hour. Change the password from the generated default. ` ;
if ( els . createAccount ) els . createAccount . hidden = true ;
2026-06-02 02:35:22 +09:00
}
2026-06-01 18:27:25 +09:00
function focusAssetInWorld ( asset ) {
const placed = state . placed . find ( ( p ) => p . assetId === asset . id ) ;
const dyn = state . dynamicSummons . find ( ( p ) => p . assetId === asset . id ) ;
const target = placed ? { x : placed . x + . 5 , y : placed . y + . 5 } : dyn ? { x : dyn . homeX + . 5 , y : dyn . homeY + . 5 } : null ;
if ( ! target ) {
selectedObject = null ;
setMode ( 'place' ) ;
toast ( 'Selected. Place it on the map.' ) ;
return ;
}
const pos = tileToWorld ( target . x , target . y ) ;
pos . y -= getLiftAtCoord ( target . x , target . y ) ;
view . x = cw / 2 - pos . x * view . zoom ;
view . y = ch / 2 - pos . y * view . zoom ;
if ( placed ) selectWorldObject ( 'static' , placed . id , asset . id , performance . now ( ) ) ;
else if ( dyn ) selectWorldObject ( 'dynamic' , dyn . id , asset . id , performance . now ( ) ) ;
setMode ( 'inspect' ) ;
}
function renderLibrary ( ) {
els . assetList . innerHTML = '' ;
2026-06-02 02:35:22 +09:00
const visibleAssets = state . assets . filter ( ( asset ) => ! isModeratedAssetHidden ( asset ) && ! state . hiddenAssets ? . [ asset . id ] ) ;
2026-06-01 21:17:09 +09:00
if ( ! visibleAssets . length ) {
els . assetList . textContent = 'No visible assets.' ;
renderHiddenAssets ( ) ;
return ;
}
const myName = ( state . authorName || 'Local Artist' ) . trim ( ) || 'Local Artist' ;
const matchesFilter = ( asset ) => libraryFilter === 'all' || subtypeToRole ( asset ) === libraryFilter ;
const mine = visibleAssets . filter ( ( asset ) => ( asset . author || 'Local Artist' ) === myName && matchesFilter ( asset ) ) ;
const others = visibleAssets . filter ( ( asset ) => ( asset . author || 'Local Artist' ) !== myName && matchesFilter ( asset ) ) ;
addLibrarySection ( 'My works' , mine ) ;
addLibrarySection ( 'Others' , others ) ;
renderHiddenAssets ( ) ;
2026-06-02 02:35:22 +09:00
renderLikedCodex ( ) ;
2026-06-01 21:17:09 +09:00
}
function addLibrarySection ( title , assets ) {
const heading = document . createElement ( 'button' ) ;
heading . type = 'button' ;
heading . className = 'librarySectionTitle' ;
heading . textContent = ` ${ title } · ${ libraryFilterLabel ( ) } ` ;
heading . title = 'Click to filter by genre' ;
heading . addEventListener ( 'click' , ( ) => {
cycleLibraryFilter ( ) ;
renderLibrary ( ) ;
} ) ;
els . assetList . append ( heading ) ;
if ( ! assets . length ) {
const empty = document . createElement ( 'div' ) ;
empty . className = 'libraryEmptyNote' ;
empty . textContent = 'No assets in this genre.' ;
els . assetList . append ( empty ) ;
2026-06-01 18:27:25 +09:00
return ;
}
2026-06-01 21:17:09 +09:00
const grid = document . createElement ( 'div' ) ;
grid . className = 'assetSectionGrid' ;
for ( const asset of assets ) grid . append ( makeAssetCard ( asset ) ) ;
els . assetList . append ( grid ) ;
}
function libraryFilterLabel ( ) {
return libraryFilter === 'all' ? 'All' : cap ( libraryFilter ) ;
}
function cycleLibraryFilter ( ) {
2026-06-02 00:05:03 +09:00
const filters = [ 'all' , 'human' , 'animal' , 'nature' , 'building' , 'ship' , 'other' ] ;
2026-06-01 21:17:09 +09:00
const index = filters . indexOf ( libraryFilter ) ;
libraryFilter = filters [ ( index + 1 ) % filters . length ] ;
2026-06-02 16:26:44 +09:00
toast ( ` Collection filter: ${ libraryFilterLabel ( ) } ` ) ;
2026-06-01 21:17:09 +09:00
}
function makeAssetCard ( asset ) {
const card = document . createElement ( 'article' ) ;
card . className = ` assetCard ${ asset . id === selectedAssetId ? ' selected' : '' } ` ;
const preview = document . createElement ( 'canvas' ) ;
preview . className = 'assetPreview' ;
preview . width = 64 ;
preview . height = 64 ;
drawPreview ( preview , asset ) ;
const meta = document . createElement ( 'div' ) ;
meta . className = 'assetMeta' ;
const lineage = asset . parentAssetId ? ' / derivative' : '' ;
meta . innerHTML = ` <strong></strong><span> ${ displayCategory ( asset ) } / ${ cap ( asset . subtype ) } / ${ asset . size } × ${ asset . size } ${ lineage } </span><span></span> ` ;
meta . querySelector ( 'strong' ) . textContent = asset . name ;
meta . querySelectorAll ( 'span' ) [ 1 ] . textContent = ` Author: ${ asset . author || 'Local Artist' } · Remixed: ${ getRemixCount ( asset . id ) } ` ;
card . addEventListener ( 'click' , ( ) => {
selectedAssetId = asset . id ;
updateSelectedLabel ( ) ;
renderLibrary ( ) ;
focusAssetInWorld ( asset ) ;
} ) ;
2026-06-01 18:27:25 +09:00
2026-06-01 21:17:09 +09:00
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 ) ;
2026-06-02 02:35:22 +09:00
const isMine = ( asset . author || 'Local Artist' ) === ( state . authorName || 'Local Artist' ) ;
const remix = makeButton ( 'Remix' , ( event ) => {
2026-06-01 21:17:09 +09:00
event ? . stopPropagation ? . ( ) ;
2026-06-02 02:35:22 +09:00
remixEdit ( asset ) ;
2026-06-01 21:17:09 +09:00
} ) ;
2026-06-02 02:35:22 +09:00
const move = isMine ? makeButton ( 'Place/Move' , ( event ) => {
2026-06-02 00:05:03 +09:00
event ? . stopPropagation ? . ( ) ;
selectedAssetId = asset . id ;
updateSelectedLabel ( ) ;
renderLibrary ( ) ;
setMode ( 'place' ) ;
setDrawerOpen ( false ) ;
toast ( 'Click a valid tile to place or move it.' ) ;
2026-06-02 02:35:22 +09:00
} ) : null ;
2026-06-01 21:17:09 +09:00
const del = makeButton ( 'Delete' , ( event ) => {
event ? . stopPropagation ? . ( ) ;
deleteAsset ( asset ) ;
} ) ;
del . classList . add ( 'danger' ) ;
2026-06-02 02:35:22 +09:00
actions . append ( up , down , hide , remix ) ;
if ( move ) actions . append ( move ) ;
if ( isMine ) actions . append ( del ) ;
2026-06-01 21:17:09 +09:00
meta . append ( actions ) ;
card . append ( preview , meta ) ;
return card ;
}
2026-06-02 02:35:22 +09:00
function renderLikedCodex ( ) {
if ( ! els . likedCodex ) return ;
const voter = currentVoterKey ( ) ;
const likedIds = new Set ( ) ;
for ( const [ assetId , votes ] of Object . entries ( state . assetVotes || { } ) ) {
if ( ( votes ? . voters || { } ) [ voter ] > 0 ) likedIds . add ( assetId ) ;
}
for ( const [ objectId , votes ] of Object . entries ( state . objectVotes || { } ) ) {
if ( ( votes ? . voters || { } ) [ voter ] > 0 ) {
const obj = [ ... ( state . placed || [ ] ) , ... ( state . dynamicSummons || [ ] ) ] . find ( ( item ) => item . id === objectId ) ;
if ( obj ? . assetId ) likedIds . add ( obj . assetId ) ;
}
}
const liked = [ ... likedIds ] . map ( findAsset ) . filter ( Boolean ) . slice ( 0 , 12 ) ;
if ( ! liked . length ) {
2026-06-02 16:26:44 +09:00
els . likedCodex . innerHTML = '<div class="likedCodexTitle">Favorite</div><div class="hint">Works you upvote appear here.</div>' ;
2026-06-02 02:35:22 +09:00
return ;
}
2026-06-02 16:26:44 +09:00
els . likedCodex . innerHTML = '<div class="likedCodexTitle">Favorite</div><div class="likedCodexList"></div>' ;
2026-06-02 02:35:22 +09:00
const list = els . likedCodex . querySelector ( '.likedCodexList' ) ;
for ( const asset of liked ) {
const item = document . createElement ( 'button' ) ;
item . type = 'button' ;
item . className = 'likedCodexItem' ;
const canvas = document . createElement ( 'canvas' ) ;
canvas . width = 32 ; canvas . height = 32 ;
drawPreview ( canvas , asset ) ;
const label = document . createElement ( 'span' ) ;
label . textContent = asset . name ;
item . append ( canvas , label ) ;
item . addEventListener ( 'click' , ( ) => { selectedAssetId = asset . id ; updateSelectedLabel ( ) ; focusAssetInWorld ( asset ) ; } ) ;
list . append ( item ) ;
}
}
2026-06-01 21:17:09 +09:00
function renderHiddenAssets ( ) {
if ( ! els . hiddenAssetList ) return ;
els . hiddenAssetList . innerHTML = '' ;
2026-06-02 00:05:03 +09:00
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.' ;
2026-06-01 21:17:09 +09:00
return ;
}
2026-06-02 00:05:03 +09:00
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' ;
2026-06-02 16:26:44 +09:00
title . textContent = 'Hidden collection works' ;
2026-06-02 00:05:03 +09:00
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 } ) ;
2026-06-01 18:27:25 +09:00
}
2026-06-02 00:05:03 +09:00
return out ;
}
2026-06-02 02:35:22 +09:00
function hiddenReasonLabel ( object ) {
if ( ! object ) return 'Hidden locally' ;
2026-06-02 16:26:44 +09:00
if ( object . status === 'hidden_rotation' ) return 'Not shown on island: exhibition is full. Works remain in your Collection.' ;
2026-06-02 02:35:22 +09:00
if ( object . status === 'permanent_hidden' || object . status === 'violation_hidden' ) return 'Hidden by moderation. This work is not visible to authors or viewers.' ;
2026-06-02 16:26:44 +09:00
if ( object . hiddenReason === 'rotation_cap' || object . hiddenReason === 'extreme_downvotes' ) return 'Not shown on island: exhibition is full. Works remain in your Collection.' ;
2026-06-02 02:35:22 +09:00
return 'Hidden locally' ;
}
2026-06-02 00:05:03 +09:00
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 = ` <strong></strong><span></span><span></span> ` ;
meta . querySelector ( 'strong' ) . textContent = asset . name ;
meta . querySelectorAll ( 'span' ) [ 0 ] . textContent = ` ${ cap ( kind ) } object / ${ location } ` ;
2026-06-02 02:35:22 +09:00
meta . querySelectorAll ( 'span' ) [ 1 ] . textContent = report ? ` Hidden locally after report: ${ report . reason } ` : hiddenReasonLabel ( object ) ;
2026-06-02 00:05:03 +09:00
const actions = document . createElement ( 'div' ) ;
actions . className = 'assetActions' ;
const restore = makeButton ( 'Show again' , ( event ) => {
event . stopPropagation ( ) ;
2026-06-02 02:35:22 +09:00
if ( isPermanentlyHiddenObject ( object ) ) {
toast ( 'This object is permanently hidden.' ) ;
return ;
}
if ( ! canPublishObject ( 'republish' ) ) return ;
2026-06-02 00:05:03 +09:00
delete state . hiddenObjects [ object . id ] ;
2026-06-02 02:35:22 +09:00
recordObjectPublish ( kind , object , 'republish' ) ;
2026-06-02 00:05:03 +09:00
saveState ( ) ;
hydrateRuntime ( ) ;
renderLibrary ( ) ;
updateSelectionBubble ( performance . now ( ) ) ;
2026-06-02 02:35:22 +09:00
toast ( 'Object republished.' ) ;
2026-06-02 00:05:03 +09:00
} ) ;
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 = ` <strong></strong><span>Author: ${ asset . author || 'Local Artist' } </span> ` ;
meta . querySelector ( 'strong' ) . textContent = asset . name ;
const actions = document . createElement ( 'div' ) ;
actions . className = 'assetActions' ;
const restore = makeButton ( 'Show again' , ( event ) => {
event . stopPropagation ( ) ;
delete state . hiddenAssets [ asset . id ] ;
saveState ( ) ;
renderLibrary ( ) ;
updateSelectionBubble ( performance . now ( ) ) ;
toast ( 'Asset shown again.' ) ;
} ) ;
actions . append ( restore ) ;
meta . append ( actions ) ;
row . append ( preview , meta ) ;
return row ;
2026-06-01 18:27:25 +09:00
}
function drawPreview ( canvas , asset ) {
const c = canvas . getContext ( '2d' ) ;
c . imageSmoothingEnabled = false ;
c . clearRect ( 0 , 0 , canvas . width , canvas . height ) ;
c . fillStyle = '#fff3d9' ;
c . fillRect ( 0 , 0 , canvas . width , canvas . height ) ;
const pixels = getAssetPixels ( asset , 'right' ) ;
const scale = Math . floor ( 48 / asset . size ) || 1 ;
const ox = Math . floor ( ( canvas . width - asset . size * scale ) / 2 ) ;
const oy = Math . floor ( ( canvas . height - asset . size * scale ) / 2 ) ;
for ( let y = 0 ; y < asset . size ; y ++ ) {
for ( let x = 0 ; x < asset . size ; x ++ ) {
const color = pixels [ y * asset . size + x ] ;
if ( ! color ) continue ;
c . fillStyle = colorToHex ( color ) ;
c . fillRect ( ox + x * scale , oy + y * scale , scale , scale ) ;
}
}
}
function renderPalette ( ) {
if ( ! els . paletteGrid ) return ;
els . paletteGrid . innerHTML = '' ;
for ( const entry of PALETTE ) {
const swatch = document . createElement ( 'button' ) ;
swatch . type = 'button' ;
swatch . className = ` paletteSwatch ${ entry . code === selectedColorCode ? ' active' : '' } ` ;
swatch . dataset . code = entry . code ;
swatch . title = ` ${ entry . code } ${ entry . color } ` ;
swatch . style . background = entry . color ;
2026-06-02 16:26:44 +09:00
const choose = ( event ) => {
event . preventDefault ( ) ;
event . stopPropagation ( ) ;
2026-06-01 22:48:15 +09:00
selectPaletteCode ( entry . code ) ;
2026-06-01 21:17:09 +09:00
drawEditor ( ) ;
2026-06-02 16:26:44 +09:00
} ;
swatch . addEventListener ( 'pointerdown' , choose ) ;
swatch . addEventListener ( 'click' , ( event ) => event . preventDefault ( ) ) ;
2026-06-01 18:27:25 +09:00
els . paletteGrid . append ( swatch ) ;
}
els . paintColor . value = PALETTE _BY _CODE [ selectedColorCode ] || '#6bd06b' ;
}
function displayCategory ( asset ) {
2026-06-02 00:05:03 +09:00
return asset . category === 'dynamic' ? 'People & Animals' : ( asset . subtype === 'ship' ? 'Ships' : 'Buildings & Nature' ) ;
2026-06-01 18:27:25 +09:00
}
function makeButton ( label , onClick ) {
const button = document . createElement ( 'button' ) ;
button . type = 'button' ;
button . textContent = label ;
button . addEventListener ( 'click' , onClick ) ;
return button ;
}
2026-06-02 02:35:22 +09:00
function loadAssetIntoEditor ( asset , mode = 'remix' ) {
2026-06-01 18:27:25 +09:00
setDrawerOpen ( true ) ;
setTab ( 'draw' ) ;
2026-06-02 16:26:44 +09:00
const editExisting = false ;
editingAssetId = null ;
editParentId = asset . id ;
editOriginalId = asset . originalAssetId || asset . id ;
els . assetName . value = ` ${ asset . name } Remix ` ;
2026-06-01 18:27:25 +09:00
els . assetCategory . value = subtypeToRole ( asset ) ;
staticKind = asset . category === 'static' ? asset . subtype : staticKind ;
dynamicKind = asset . category === 'dynamic' ? asset . subtype : dynamicKind ;
editingSide = 'right' ;
2026-06-01 22:48:15 +09:00
const right = getAssetPixels ( asset , 'right' ) ;
2026-06-01 18:27:25 +09:00
lightPixels = ( asset . meta ? . lightPixels || [ ] ) . map ( ( p ) => ( { x : p . x , y : p . y , c : p . c || asset . meta ? . lightColor || selectedColorCode } ) ) ;
2026-06-02 16:26:44 +09:00
particlePixels = ( asset . meta ? . particlePixels || [ ] ) . map ( ( p ) => ( { x : p . x , y : p . y , c : p . c || selectedColorCode , dir : p . dir || 'up' } ) ) ;
particleConfig = normalizeParticleConfig ( ( particlePixels . length ? { enabled : true , c : particlePixels [ 0 ] . c || selectedColorCode , dir : particlePixels [ 0 ] . dir || 'up' } : { enabled : false , c : selectedColorCode , dir : 'up' } ) , asset . size ) ;
2026-06-01 21:17:09 +09:00
depthPixels = normalizeDepthPixels ( asset . meta ? . depthPixels || [ ] , asset . size ) ;
2026-06-01 18:27:25 +09:00
doorPixel = asset . meta ? . door || { x : Math . floor ( asset . size / 2 ) , y : asset . size - 1 } ;
2026-06-01 22:48:15 +09:00
setupEditor ( asset . size , right , null , depthPixels ) ;
clearEditorHistory ( ) ;
2026-06-01 18:27:25 +09:00
refreshCategoryUI ( ) ;
2026-06-02 16:26:44 +09:00
els . lineageNote . textContent = ` Remixing “ ${ asset . name } ”. Save creates a separate new collection work. ` ;
2026-06-01 18:27:25 +09:00
}
2026-06-02 02:35:22 +09:00
function editOriginalAsset ( asset ) {
remixEdit ( asset ) ;
}
function remixEdit ( asset ) {
loadAssetIntoEditor ( asset , 'remix' ) ;
}
function copyEdit ( asset ) {
remixEdit ( asset ) ;
}
2026-06-01 18:27:25 +09:00
function deleteAsset ( asset ) {
2026-06-02 16:26:44 +09:00
if ( ! asset ) return ;
2026-06-01 18:27:25 +09:00
const used = state . placed . some ( ( p ) => p . assetId === asset . id ) || state . dynamicSummons . some ( ( p ) => p . assetId === asset . id ) ;
2026-06-02 16:26:44 +09:00
const usageText = used ? ' Island placements that use it will also be removed.' : '' ;
if ( ! confirm ( ` Delete “ ${ asset . name } ”? ${ usageText } ` ) ) return ;
const removedObjectIds = new Set ( ) ;
for ( const placed of state . placed || [ ] ) if ( placed . assetId === asset . id ) removedObjectIds . add ( placed . id ) ;
for ( const summon of state . dynamicSummons || [ ] ) if ( summon . assetId === asset . id ) removedObjectIds . add ( summon . id ) ;
2026-06-01 18:27:25 +09:00
state . assets = state . assets . filter ( ( a ) => a . id !== asset . id ) ;
state . placed = state . placed . filter ( ( p ) => p . assetId !== asset . id ) ;
state . dynamicSummons = state . dynamicSummons . filter ( ( p ) => p . assetId !== asset . id ) ;
2026-06-02 16:26:44 +09:00
delete state . assetVotes ? . [ asset . id ] ;
delete state . hiddenAssets ? . [ asset . id ] ;
for ( const id of removedObjectIds ) {
delete state . objectVotes ? . [ id ] ;
delete state . hiddenObjects ? . [ id ] ;
}
state . moderationReports = ( state . moderationReports || [ ] ) . filter ( ( report ) => ! removedObjectIds . has ( report . objectId ) ) ;
2026-06-01 18:27:25 +09:00
if ( selectedAssetId === asset . id ) selectedAssetId = state . assets [ 0 ] ? . id ? ? null ;
2026-06-02 16:26:44 +09:00
if ( selectedObject && ( selectedObject . assetId === asset . id || removedObjectIds . has ( selectedObject . id ) ) ) selectedObject = null ;
recordSyncEvent ( Phase2Sync ? . createAssetDeleteEvent ? . ( asset . id ) ) ;
2026-06-01 18:27:25 +09:00
saveState ( ) ;
2026-06-01 21:17:09 +09:00
spriteCache . clear ( ) ;
2026-06-01 18:27:25 +09:00
hydrateRuntime ( ) ;
renderLibrary ( ) ;
updateSelectedLabel ( ) ;
2026-06-02 16:26:44 +09:00
updateSelectionBubble ( performance . now ( ) ) ;
toast ( ` ${ asset . name } deleted. ` ) ;
2026-06-01 18:27:25 +09:00
}
function hydrateRuntime ( ) {
dynamicRuntime = state . dynamicSummons . map ( ( summon ) => {
const asset = findAsset ( summon . assetId ) ;
if ( ! asset ) return null ;
const homeTile = world . get ( Math . round ( summon . homeX ) , Math . round ( summon . homeY ) ) ;
const homeValid = homeTile && ( ( asset . subtype === 'fish' ) ? homeTile . type === 'water' : homeTile . type !== 'water' ) ;
const spawn = homeValid ? { x : Math . round ( summon . homeX ) , y : Math . round ( summon . homeY ) } : findValidSpawn ( asset , summon . homeX , summon . homeY , 6 ) ;
return {
id : summon . id ,
assetId : summon . assetId ,
homeX : summon . homeX ,
homeY : summon . homeY ,
x : spawn . x + . 5 ,
y : spawn . y + . 5 ,
targetX : spawn . x + . 5 ,
targetY : spawn . y + . 5 ,
2026-06-01 22:48:15 +09:00
vx : 0 ,
2026-06-02 16:26:44 +09:00
lastMoveX : 0 ,
2026-06-01 22:48:15 +09:00
facing : 1 ,
idleUntil : performance . now ( ) + 700 + Math . random ( ) * 1600 ,
2026-06-01 18:27:25 +09:00
hiddenUntil : 0 ,
seed : Math . random ( ) * 9999 ,
nextDecisionAt : 0 ,
2026-06-02 00:05:03 +09:00
nextBubbleAt : 800 + Math . random ( ) * 1500 ,
2026-06-02 02:35:22 +09:00
nextStepParticleAt : performance . now ( ) + 300 + Math . random ( ) * 280
2026-06-01 18:27:25 +09:00
} ;
} ) . filter ( Boolean ) ;
}
function findValidSpawn ( asset , homeX , homeY , radius ) {
if ( asset . subtype === 'bird' ) {
const x = clamp ( Math . round ( homeX ) , 0 , WORLD _W - 1 ) ;
const y = clamp ( Math . round ( homeY ) , 0 , WORLD _H - 1 ) ;
if ( world . get ( x , y ) ? . type !== 'water' ) return { x , y } ;
}
const candidates = [ ] ;
for ( let dy = - radius ; dy <= radius ; dy ++ ) {
for ( let dx = - radius ; dx <= radius ; dx ++ ) {
const x = Math . round ( homeX + dx ) ;
const y = Math . round ( homeY + dy ) ;
if ( x < 0 || y < 0 || x >= WORLD _W || y >= WORLD _H ) continue ;
const tile = world . get ( x , y ) ;
if ( asset . subtype === 'fish' ) {
if ( tile . type === 'water' ) candidates . push ( { x , y } ) ;
} else if ( tile . type !== 'water' ) candidates . push ( { x , y } ) ;
}
}
if ( ! candidates . length ) return { x : clamp ( Math . round ( homeX ) , 0 , WORLD _W - 1 ) , y : clamp ( Math . round ( homeY ) , 0 , WORLD _H - 1 ) } ;
return candidates [ Math . floor ( Math . random ( ) * candidates . length ) ] ;
}
function updateDynamicRuntime ( dt , time ) {
bubbleParticles = bubbleParticles . filter ( ( p ) => time - p . started < p . life ) ;
confettiParticles = confettiParticles . filter ( ( p ) => time - p . started < p . life ) ;
2026-06-02 00:05:03 +09:00
landStepParticles = landStepParticles . filter ( ( p ) => time - p . started < p . life ) ;
natureDriftParticles = natureDriftParticles . filter ( ( p ) => time - p . started < p . life ) ;
2026-06-01 18:27:25 +09:00
for ( const item of dynamicRuntime ) {
const asset = findAsset ( item . assetId ) ;
if ( ! asset ) continue ;
if ( time < item . hiddenUntil ) continue ;
2026-06-02 00:05:03 +09:00
if ( visualSettings ( ) . enableParticles && asset . subtype === 'fish' && time > ( item . nextBubbleAt || 0 ) ) {
2026-06-01 18:27:25 +09:00
spawnFishBubbleCluster ( item . x , item . y , time , item . seed ) ;
item . nextBubbleAt = time + 1100 + Math . random ( ) * 1800 ;
}
2026-06-01 22:48:15 +09:00
if ( time < ( item . idleUntil || 0 ) ) {
item . vx = 0 ;
2026-06-02 16:26:44 +09:00
item . lastMoveX = 0 ;
2026-06-01 22:48:15 +09:00
continue ;
}
2026-06-01 21:17:09 +09:00
const distance = Math . hypot ( item . targetX - item . x , item . targetY - item . y ) ;
2026-06-01 18:27:25 +09:00
if ( distance < . 15 || time > item . nextDecisionAt ) chooseTarget ( item , asset , time ) ;
2026-06-01 22:48:15 +09:00
if ( time < ( item . idleUntil || 0 ) ) {
item . vx = 0 ;
2026-06-02 16:26:44 +09:00
item . lastMoveX = 0 ;
2026-06-01 22:48:15 +09:00
continue ;
}
2026-06-01 18:27:25 +09:00
2026-06-01 21:17:09 +09:00
const dx = item . targetX - item . x ;
const dy = item . targetY - item . y ;
const len = Math . hypot ( dx , dy ) || 1 ;
2026-06-01 22:48:15 +09:00
const speed = ( { human : . 77 , animal : . 67 , car : . 37 , fish : . 47 , bird : . 75 } [ asset . subtype ] || . 53 ) * dt ;
const step = Math . min ( speed , len ) ;
2026-06-02 16:26:44 +09:00
const moveX = ( dx / len ) * step ;
const moveY = ( dy / len ) * step ;
if ( Math . abs ( moveX ) > 0.003 ) item . facing = Math . sign ( moveX ) ;
item . vx = Math . abs ( moveX ) > 0.003 ? Math . sign ( moveX ) : 0 ;
item . lastMoveX = moveX ;
item . x += moveX ;
item . y += moveY ;
2026-06-01 18:27:25 +09:00
2026-06-02 00:05:03 +09:00
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 ) ;
}
2026-06-02 02:35:22 +09:00
item . nextStepParticleAt = time + 300 + Math . random ( ) * 320 ;
2026-06-02 00:05:03 +09:00
}
2026-06-01 18:27:25 +09:00
if ( asset . subtype === 'human' && distance < . 3 && Math . random ( ) < . 004 ) {
item . hiddenUntil = time + 1700 + Math . random ( ) * 2200 ;
}
}
}
function chooseTarget ( item , asset , time ) {
2026-06-01 22:48:15 +09:00
const idleChance = ( { human : . 34 , animal : . 42 , fish : . 24 , bird : . 16 } [ asset . subtype ] || . 32 ) ;
if ( Math . random ( ) < idleChance ) {
item . idleUntil = time + 900 + Math . random ( ) * 2600 ;
item . targetX = item . x ;
item . targetY = item . y ;
item . vx = 0 ;
2026-06-02 16:26:44 +09:00
item . lastMoveX = 0 ;
2026-06-01 22:48:15 +09:00
item . nextDecisionAt = item . idleUntil + 400 + Math . random ( ) * 1200 ;
return ;
}
2026-06-01 18:27:25 +09:00
let target = null ;
if ( asset . subtype === 'human' ) target = findNearestPlaced ( item . x , item . y , 'building' , 14 ) ;
else if ( asset . subtype === 'animal' || asset . subtype === 'bird' ) target = findNearestPlaced ( item . x , item . y , 'nature' , 16 ) ;
if ( target && Math . random ( ) < . 72 ) {
item . targetX = target . x + . 5 + ( Math . random ( ) - . 5 ) * 1.8 ;
item . targetY = target . y + . 5 + ( Math . random ( ) - . 5 ) * 1.8 ;
} else {
const spawn = findValidSpawn ( asset , item . homeX , item . homeY , asset . subtype === 'bird' ? 2 : 7 ) ;
item . targetX = spawn . x + . 5 ;
item . targetY = spawn . y + . 5 ;
}
2026-06-01 22:48:15 +09:00
item . idleUntil = 0 ;
item . nextDecisionAt = time + 1800 + Math . random ( ) * 3200 ;
2026-06-01 18:27:25 +09:00
}
function findNearestPlaced ( x , y , subtype , maxDistance ) {
let best = null ;
let bestDistance = maxDistance ;
2026-06-01 22:48:15 +09:00
const minX = clampInt ( Math . floor ( x - maxDistance ) , 0 , WORLD _W - 1 , 0 ) ;
const maxX = clampInt ( Math . ceil ( x + maxDistance ) , 0 , WORLD _W - 1 , WORLD _W - 1 ) ;
const minY = clampInt ( Math . floor ( y - maxDistance ) , 0 , WORLD _H - 1 , 0 ) ;
const maxY = clampInt ( Math . ceil ( y + maxDistance ) , 0 , WORLD _H - 1 , WORLD _H - 1 ) ;
for ( let ty = minY ; ty <= maxY ; ty ++ ) {
for ( let tx = minX ; tx <= maxX ; tx ++ ) {
const bucket = worldIndex . placedByTile . get ( tileKey ( tx , ty ) ) ;
if ( ! bucket ) continue ;
for ( const placed of bucket ) {
const asset = findAsset ( placed . assetId ) ;
if ( ! asset || asset . subtype !== subtype ) continue ;
const distance = Math . hypot ( placed . x + . 5 - x , placed . y + . 5 - y ) ;
if ( distance < bestDistance ) {
bestDistance = distance ;
best = placed ;
}
}
2026-06-01 18:27:25 +09:00
}
}
return best ;
}
2026-06-01 22:48:15 +09:00
function scheduleFrame ( ) {
if ( animationFrameId || document . hidden ) return ;
animationFrameId = requestAnimationFrame ( tick ) ;
}
function onVisibilityChange ( ) {
if ( document . hidden ) {
if ( animationFrameId ) cancelAnimationFrame ( animationFrameId ) ;
animationFrameId = 0 ;
lastRuntimeUpdate = performance . now ( ) ;
dynamicLogicRemainder = 0 ;
return ;
}
lastRuntimeUpdate = performance . now ( ) ;
dynamicLogicRemainder = 0 ;
render ( lastRuntimeUpdate ) ;
scheduleFrame ( ) ;
}
2026-06-01 18:27:25 +09:00
function tick ( time ) {
2026-06-01 22:48:15 +09:00
animationFrameId = 0 ;
if ( document . hidden ) {
lastRuntimeUpdate = time ;
return ;
}
const elapsed = Math . min ( 100 , Math . max ( 0 , time - lastRuntimeUpdate ) ) ;
2026-06-01 18:27:25 +09:00
lastRuntimeUpdate = time ;
2026-06-01 22:48:15 +09:00
dynamicLogicRemainder += elapsed ;
let steps = 0 ;
while ( dynamicLogicRemainder >= DYNAMIC _LOGIC _STEP _MS && steps < MAX _DYNAMIC _STEPS _PER _FRAME ) {
updateDynamicRuntime ( DYNAMIC _LOGIC _STEP _MS / 1000 , time ) ;
dynamicLogicRemainder -= DYNAMIC _LOGIC _STEP _MS ;
steps ++ ;
}
if ( steps >= MAX _DYNAMIC _STEPS _PER _FRAME ) dynamicLogicRemainder = 0 ;
2026-06-01 18:27:25 +09:00
updateClock ( ) ;
render ( time ) ;
2026-06-01 22:48:15 +09:00
scheduleFrame ( ) ;
2026-06-01 18:27:25 +09:00
}
function updateClock ( ) {
const phase = getPhase ( ) ;
2026-06-02 16:26:44 +09:00
const progress = phase . progress || 0 ;
const degrees = progress * 360 ;
if ( els . analogClockHand ) els . analogClockHand . style . transform = ` translate(-50%, -100%) rotate( ${ degrees . toFixed ( 2 ) } deg) ` ;
if ( els . analogClock ) {
els . analogClock . style . setProperty ( '--clock-rotate' , ` ${ degrees . toFixed ( 2 ) } deg ` ) ;
els . analogClock . setAttribute ( 'aria-label' , ` ${ phase . label } island clock ` ) ;
els . analogClock . title = ` ${ phase . label } · 10 min = 1 island day ` ;
}
2026-06-01 18:27:25 +09:00
const second = Math . floor ( Date . now ( ) / 1000 ) ;
2026-06-02 16:26:44 +09:00
if ( second !== lastClockSecond ) {
lastClockSecond = second ;
if ( els . phaseLabel ) els . phaseLabel . textContent = phase . label ;
}
if ( els . phaseBar ) els . phaseBar . style . width = ` ${ Math . round ( progress * 100 ) } % ` ;
2026-06-01 18:27:25 +09:00
}
function getShadowForMinute ( minute ) {
2026-06-02 16:26:44 +09:00
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 ;
let length = 1.2 ;
let alpha = 0 ;
let scaleY = 0.26 ;
if ( m < 0.65 ) {
// Pre-dawn: moon shadow fades out completely before morning begins.
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 ) {
// Morning: sunlight shadow appears from zero.
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 ) {
// Day: rotate gradually with the sun, no hard switch in the evening.
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 ) {
// Evening: keep the same sun-side direction and fade to zero before night.
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 ) {
// Night starts after the sunlight shadow disappears; moon shadow fades in.
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 {
// Before dawn: moon shadow darkens/fades out to zero, then morning creates a new sun shadow.
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 ) ;
}
2026-06-01 18:27:25 +09:00
return {
2026-06-01 21:17:09 +09:00
dirX ,
length ,
skewX : dirX * ( 0.72 + 0.32 * length ) ,
2026-06-02 16:26:44 +09:00
scaleY ,
alpha
2026-06-01 18:27:25 +09:00
} ;
}
function getPhase ( ) {
2026-06-02 00:05:03 +09:00
if ( visualSettings ( ) . enableDayNight === false ) {
2026-06-02 16:26:44 +09:00
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 ) } ;
2026-06-02 00:05:03 +09:00
}
2026-06-01 18:27:25 +09:00
const t = mod ( Date . now ( ) , DAY _MS ) ;
const minute = t / 60000 ;
const stops = [
2026-06-02 16:26:44 +09:00
{ 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 ] }
2026-06-01 18:27:25 +09:00
] ;
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 ; }
}
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 ) ) ;
2026-06-02 16:26:44 +09:00
const overlay = a . overlay . map ( ( v , i ) => lerp ( v , b . overlay [ i ] , eased ) ) ;
const darkness = lerp ( a . darkness , b . darkness , eased ) ;
const mixSky = mixHex ( a . sky , b . sky , 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' ) ) ;
const label = dominantKey === 'night' ? 'Night' : dominantKey === 'evening' ? 'Evening' : dominantKey === 'morning' ? 'Morning' : 'Day' ;
2026-06-01 18:27:25 +09:00
return {
2026-06-02 16:26:44 +09:00
key : dominantKey ,
2026-06-01 18:27:25 +09:00
label ,
progress : t / DAY _MS ,
2026-06-02 16:26:44 +09:00
sky : mixSky ,
darkness ,
darkOverlay : ` rgba( ${ Math . round ( overlay [ 0 ] ) } , ${ Math . round ( overlay [ 1 ] ) } , ${ Math . round ( overlay [ 2 ] ) } , ${ darkness . toFixed ( 3 ) } ) ` ,
2026-06-01 18:27:25 +09:00
tint : ` rgba( ${ Math . round ( tint [ 0 ] ) } , ${ Math . round ( tint [ 1 ] ) } , ${ Math . round ( tint [ 2 ] ) } , ${ tint [ 3 ] . toFixed ( 3 ) } ) ` ,
2026-06-02 00:05:03 +09:00
tintAlpha : tint [ 3 ] ,
2026-06-01 18:27:25 +09:00
shadow : getShadowForMinute ( minute )
} ;
}
2026-06-02 16:26:44 +09:00
function areNightLightsActive ( phase = renderPhase ) {
return visualSettings ( ) . enableLights !== false && phase ? . key === 'night' ;
}
function collectLightSourcesForItems ( items , time , phase ) {
if ( ! areNightLightsActive ( phase ) || ! items ? . length ) return [ ] ;
const sources = [ ] ;
for ( const item of items ) {
const asset = item . asset ;
if ( ! asset . meta ? . hasLight || ! asset . meta . lightPixels ? . length ) continue ;
const info = getSpriteDrawInfo ( item , time , true ) ;
const scale = asset . category === 'static' ? STATIC _SCALE : DYNAMIC _SCALE ;
const pivotX = info . drawX + info . sprite . width / 2 ;
const pivotY = info . drawY + info . sprite . height * 0.8 ;
for ( const light of asset . meta . lightPixels ) {
const lx = asset . category === 'dynamic' && info . side === 'left' ? asset . size - 1 - light . x : light . x ;
let wx = info . drawX + ( lx + 0.5 ) * scale ;
let wy = info . drawY + ( light . y + 0.5 ) * scale ;
if ( info . angle ) {
const dx = wx - pivotX ;
const dy = wy - pivotY ;
const cos = Math . cos ( info . angle ) ;
const sin = Math . sin ( info . angle ) ;
wx = pivotX + dx * cos - dy * sin ;
wy = pivotY + dx * sin + dy * cos ;
}
sources . push ( {
x : wx ,
y : wy ,
color : colorToHex ( light . c || asset . meta . lightColor || nearestPaletteCode ( '#ffd86a' ) ) ,
radius : lerp ( 13 , 22 , asset . size / 64 ) ,
ownerId : item . source ? . id || asset . id ,
flickerSeed : parseInt ( fnv1a ( ` ${ item . source ? . id || asset . id } : ${ light . x } , ${ light . y } ` ) . slice ( 0 , 6 ) , 16 ) || 1
} ) ;
}
}
return sources ;
}
2026-06-01 18:27:25 +09:00
function render ( time = performance . now ( ) ) {
2026-06-02 00:05:03 +09:00
const phase = getPhase ( ) ;
renderPhase = phase ;
2026-06-01 18:27:25 +09:00
ctx . setTransform ( dpr , 0 , 0 , dpr , 0 , 0 ) ;
ctx . clearRect ( 0 , 0 , cw , ch ) ;
2026-06-02 00:05:03 +09:00
ctx . fillStyle = phase . sky || '#86d5ff' ;
2026-06-01 18:27:25 +09:00
ctx . fillRect ( 0 , 0 , cw , ch ) ;
ctx . save ( ) ;
ctx . translate ( view . x , view . y ) ;
ctx . scale ( view . zoom , view . zoom ) ;
ctx . imageSmoothingEnabled = false ;
2026-06-01 22:48:15 +09:00
drawTerrainCache ( terrainCache ) ;
2026-06-01 21:17:09 +09:00
drawHoverTile ( ) ;
2026-06-01 18:27:25 +09:00
const lightSources = [ ] ;
2026-06-02 00:05:03 +09:00
const visibleItems = drawObjects ( time , lightSources , phase ) ;
if ( visualSettings ( ) . enableParticles ) {
2026-06-02 02:35:22 +09:00
spawnConfiguredParticleEmitters ( visibleItems , time ) ;
2026-06-02 00:05:03 +09:00
drawNatureDriftParticles ( time ) ;
drawLandStepParticles ( time ) ;
drawBubbleParticles ( time ) ;
drawSpawnEffects ( time ) ;
drawConfettiParticles ( time ) ;
}
2026-06-02 16:26:44 +09:00
const nightLightsActive = areNightLightsActive ( phase ) ;
2026-06-01 18:27:25 +09:00
ctx . restore ( ) ;
updateSelectionBubble ( time ) ;
2026-06-02 00:05:03 +09:00
if ( ( phase . tintAlpha || 0 ) > 0.001 ) {
2026-06-01 18:27:25 +09:00
ctx . fillStyle = phase . tint ;
ctx . fillRect ( 0 , 0 , cw , ch ) ;
}
2026-06-01 21:17:09 +09:00
if ( phase . darkness > 0 ) {
2026-06-02 16:26:44 +09:00
ctx . fillStyle = phase . darkOverlay || ` rgba(12, 19, 45, ${ phase . darkness } ) ` ;
2026-06-01 18:27:25 +09:00
ctx . fillRect ( 0 , 0 , cw , ch ) ;
}
2026-06-02 16:26:44 +09:00
if ( nightLightsActive && phase . darkness > 0 ) drawLightSources ( lightSources , phase . darkness , time ) ;
2026-06-01 18:27:25 +09:00
}
function drawHoverTile ( ) {
if ( ! hoverTile ) return ;
const { x , y } = tileToWorld ( hoverTile . x , hoverTile . y ) ;
const lift = getTileLift ( hoverTile . tile ) ;
ctx . beginPath ( ) ;
ctx . moveTo ( x , y - lift ) ;
ctx . lineTo ( x + TILE _W / 2 , y + TILE _H / 2 - lift ) ;
ctx . lineTo ( x , y + TILE _H - lift ) ;
ctx . lineTo ( x - TILE _W / 2 , y + TILE _H / 2 - lift ) ;
ctx . closePath ( ) ;
ctx . strokeStyle = 'rgba(255, 255, 255, .7)' ;
ctx . lineWidth = 1 / view . zoom ;
ctx . stroke ( ) ;
}
function drawSpawnEffects ( time ) {
if ( ! spawnEffects . length ) return ;
spawnEffects = spawnEffects . filter ( ( effect ) => time - effect . started < 650 ) ;
for ( const effect of spawnEffects ) {
const t = clamp ( ( time - effect . started ) / 650 , 0 , 1 ) ;
const pos = tileToWorld ( effect . x , effect . y ) ;
pos . y -= getLiftAtCoord ( effect . x , effect . y ) ;
const radius = 6 + t * 24 ;
ctx . save ( ) ;
ctx . globalAlpha = 1 - t ;
ctx . lineWidth = Math . max ( 1 , 3 / view . zoom ) ;
ctx . strokeStyle = '#ff79ad' ;
ctx . beginPath ( ) ;
ctx . arc ( pos . x , pos . y + TILE _H * . 35 , radius , 0 , Math . PI * 2 ) ;
ctx . stroke ( ) ;
ctx . fillStyle = '#fff2a8' ;
for ( let i = 0 ; i < 5 ; i ++ ) {
const a = i * Math . PI * . 4 + t * 2.4 ;
ctx . fillRect ( pos . x + Math . cos ( a ) * radius - 2 , pos . y + TILE _H * . 35 + Math . sin ( a ) * radius - 2 , 4 , 4 ) ;
}
ctx . restore ( ) ;
}
}
function drawObjects ( time , lightSources , phase ) {
2026-06-01 22:48:15 +09:00
const items = getDrawableItems ( time , getViewportWorldRect ( VIEW _CULL _MARGIN ) ) ;
2026-06-02 16:26:44 +09:00
const activeLights = collectLightSourcesForItems ( items , time , phase ) ;
if ( lightSources && activeLights . length ) lightSources . push ( ... activeLights ) ;
2026-06-01 18:27:25 +09:00
for ( const item of items ) {
if ( item . asset . category === 'dynamic' && item . asset . subtype === 'fish' ) {
2026-06-02 16:26:44 +09:00
drawSpriteItem ( item , time , activeLights , true , phase ) ;
2026-06-01 18:27:25 +09:00
}
}
for ( const item of items ) {
if ( ! ( item . asset . category === 'dynamic' && item . asset . subtype === 'fish' ) ) {
2026-06-02 16:26:44 +09:00
drawSpriteItem ( item , time , activeLights , false , phase ) ;
2026-06-01 18:27:25 +09:00
}
}
2026-06-02 00:05:03 +09:00
return items ;
2026-06-01 18:27:25 +09:00
}
function getSpriteDrawInfo ( item , time , includeSelectBounce = true ) {
const asset = item . asset ;
const pos = tileToWorld ( item . x , item . y ) ;
const applyTileLift = ! ( asset . category === 'dynamic' && asset . subtype === 'bird' ) ;
if ( applyTileLift ) pos . y -= getLiftAtCoord ( item . x , item . y ) ;
let bob = 0 ;
let alpha = 1 ;
let side = 'right' ;
let angle = 0 ;
2026-06-02 00:05:03 +09:00
if ( asset . category === 'static' && ( asset . subtype === 'water' || asset . subtype === 'ship' ) ) bob += Math . sin ( time / 900 + item . x * . 7 ) * 1.6 ;
2026-06-01 18:27:25 +09:00
if ( asset . category === 'dynamic' ) {
2026-06-02 16:26:44 +09:00
const runtime = item . source || { } ;
const seed = Number . isFinite ( runtime . seed ) ? runtime . seed : 0 ;
const isPreview = Boolean ( item . preview || runtime . preview ) ;
const moveFacing = Math . abs ( runtime . lastMoveX || 0 ) > 0.003 ? Math . sign ( runtime . lastMoveX ) : ( runtime . facing || runtime . vx || 1 ) ;
side = isPreview ? 'right' : ( moveFacing < 0 ? 'left' : 'right' ) ;
2026-06-01 18:27:25 +09:00
if ( asset . subtype === 'human' || asset . subtype === 'animal' ) {
2026-06-02 16:26:44 +09:00
const idle = isPreview || time < ( runtime . idleUntil || 0 ) ;
2026-06-01 22:48:15 +09:00
if ( ! idle ) {
2026-06-02 16:26:44 +09:00
const jumpPhase = time / 78 + seed * 0.9 ;
2026-06-01 22:48:15 +09:00
const hop = Math . pow ( Math . abs ( Math . sin ( jumpPhase ) ) , 1.15 ) ;
const liftNow = getLiftAtCoord ( item . x , item . y ) ;
const liftTarget = getLiftAtCoord ( runtime . targetX , runtime . targetY ) ;
const terrainBoost = Math . abs ( liftTarget - liftNow ) / 12 ;
bob = - hop * ( 2.2 + terrainBoost * 1.8 + ( liftNow > 0 ? 0.6 : 0 ) ) ;
const cycle = Math . floor ( jumpPhase / ( Math . PI * 0.9 ) ) ;
2026-06-02 16:26:44 +09:00
const rand = pseudoNoise ( cycle + seed * 13.17 ) ;
2026-06-01 22:48:15 +09:00
angle = ( rand - 0.5 ) * 0.24 * hop ;
}
2026-06-01 18:27:25 +09:00
}
2026-06-02 16:26:44 +09:00
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 ;
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 ;
}
2026-06-01 18:27:25 +09:00
}
const sprite = getSpriteCanvas ( asset , side ) ;
const drawX = pos . x - sprite . width / 2 ;
2026-06-02 00:05:03 +09:00
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 } ;
2026-06-01 18:27:25 +09:00
}
function drawSpriteItem ( item , time , lightSources , underwater , phase ) {
2026-06-02 16:26:44 +09:00
const info = getSpriteDrawInfo ( item , time , true ) ;
const { asset , pos , sprite , drawX , drawY , alpha , angle } = info ;
2026-06-01 18:27:25 +09:00
2026-06-02 00:05:03 +09:00
if ( visualSettings ( ) . enableParticles && asset . category === 'static' && asset . subtype === 'ship' ) drawShipRipples ( pos , time , item . source ? . id || asset . id ) ;
2026-06-02 16:26:44 +09:00
if ( ! ( asset . category === 'dynamic' && asset . subtype === 'fish' ) ) drawSpriteShadow ( info , phase ) ;
2026-06-01 18:27:25 +09:00
ctx . save ( ) ;
ctx . globalAlpha = alpha ;
if ( angle ) {
ctx . translate ( Math . round ( drawX + sprite . width / 2 ) , Math . round ( drawY + sprite . height * 0.8 ) ) ;
ctx . rotate ( angle ) ;
ctx . drawImage ( sprite , Math . round ( - sprite . width / 2 ) , Math . round ( - sprite . height * 0.8 ) ) ;
} else {
ctx . drawImage ( sprite , Math . round ( drawX ) , Math . round ( drawY ) ) ;
}
if ( underwater ) {
ctx . fillStyle = 'rgba(103, 181, 217, .18)' ;
if ( angle ) ctx . fillRect ( Math . round ( - sprite . width / 2 ) , Math . round ( - sprite . height * 0.8 ) , sprite . width , sprite . height ) ;
else ctx . fillRect ( Math . round ( drawX ) , Math . round ( drawY ) , sprite . width , sprite . height ) ;
}
ctx . restore ( ) ;
2026-06-02 16:26:44 +09:00
drawSpriteLightReflection ( info , item , lightSources , phase ) ;
2026-06-02 00:05:03 +09:00
}
2026-06-02 16:26:44 +09:00
function spriteLocalPoint ( info , worldX , worldY ) {
if ( ! info . angle ) return { x : worldX - info . drawX , y : worldY - info . drawY } ;
const pivotX = info . drawX + info . sprite . width / 2 ;
const pivotY = info . drawY + info . sprite . height * 0.8 ;
const dx = worldX - pivotX ;
const dy = worldY - pivotY ;
const cos = Math . cos ( - info . angle ) ;
const sin = Math . sin ( - info . angle ) ;
return {
x : dx * cos - dy * sin + info . sprite . width / 2 ,
y : dx * sin + dy * cos + info . sprite . height * 0.8
} ;
}
function drawSpriteLightReflection ( info , item , lightSources , phase ) {
if ( ! areNightLightsActive ( phase ) || ! lightSources ? . length ) return ;
const asset = item . asset ;
const ownerId = item . source ? . id || asset . id ;
const scale = asset . category === 'static' ? STATIC _SCALE : DYNAMIC _SCALE ;
const sprite = info . sprite ;
const sources = [ ] ;
for ( const source of lightSources ) {
if ( source . ownerId === ownerId ) continue ;
const local = spriteLocalPoint ( info , source . x , source . y ) ;
const dx = local . x - sprite . width / 2 ;
const dy = local . y - sprite . height / 2 ;
const reach = source . radius * 4.9 + Math . max ( sprite . width , sprite . height ) * 1.15 ;
const distance = Math . hypot ( dx , dy ) ;
if ( distance <= reach ) sources . push ( { source , local , distance } ) ;
}
if ( ! sources . length ) return ;
sources . sort ( ( a , b ) => a . distance - b . distance ) ;
const pixels = getAssetPixels ( asset , info . side || 'right' ) ;
const rawDepths = normalizeDepthPixels ( asset . meta ? . depthPixels || [ ] , asset . size || 1 ) ;
const depths = asset . category === 'dynamic' && info . side === 'left' ? mirrorDepthPixels ( rawDepths , asset . size ) : rawDepths ;
const overlay = document . createElement ( 'canvas' ) ;
overlay . width = sprite . width ;
overlay . height = sprite . height ;
const octx = overlay . getContext ( '2d' ) ;
octx . imageSmoothingEnabled = false ;
for ( let y = 0 ; y < asset . size ; y ++ ) {
for ( let x = 0 ; x < asset . size ; x ++ ) {
if ( ! pixels [ y * asset . size + x ] ) continue ;
const depth = depths [ y * asset . size + x ] || 0 ;
let rr = 0 , gg = 0 , bb = 0 , aa = 0 ;
for ( const { source , local } of sources . slice ( 0 , 3 ) ) {
const lc = parseHex ( source . color || '#ffd86a' ) ;
if ( ! lc ) continue ;
const cellPx = ( x + 0.5 ) * scale ;
const cellPy = ( y + 0.5 ) * scale ;
const d = Math . hypot ( local . x - cellPx , local . y - cellPy ) ;
const reach = Math . max ( scale * 2.2 , source . radius * 3.8 ) ;
const t = 1 - clamp ( d / reach , 0 , 1 ) ;
if ( t <= 0 ) continue ;
const edge = getDepthLightFacing ( depth , x , y , asset . size , pixels , depths , local . x / scale - 0.5 , local . y / scale - 0.5 ) ;
const depthFactor = depth > 0 ? 1.05 + edge * 0.38 : depth < 0 ? 0.72 + edge * 0.18 : 0.50 + edge * 0.10 ;
const amount = t * t * depthFactor ;
rr += lc . r * amount ;
gg += lc . g * amount ;
bb += lc . b * amount ;
aa += amount ;
}
if ( aa <= 0.002 ) continue ;
const inv = 1 / aa ;
const alpha = Math . min ( 0.34 , 0.08 + aa * 0.18 ) ;
octx . fillStyle = ` rgba( ${ Math . round ( rr * inv ) } , ${ Math . round ( gg * inv ) } , ${ Math . round ( bb * inv ) } , ${ alpha . toFixed ( 3 ) } ) ` ;
octx . fillRect ( x * scale , y * scale , scale , scale ) ;
2026-06-02 00:05:03 +09:00
}
}
2026-06-02 16:26:44 +09:00
ctx . save ( ) ;
ctx . globalAlpha = 1 ;
ctx . globalCompositeOperation = 'source-over' ;
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 drawSurfaceLightBleed ( items , lightSources , time ) {
// Kept as a no-op compatibility stub. Reflections are now drawn per sprite
// immediately after that sprite, so light overlays preserve draw order.
2026-06-02 00:05:03 +09:00
}
function spawnGroundStepParticles ( item , time , tile ) {
2026-06-02 02:35:22 +09:00
if ( Math . random ( ) > 0.55 ) return ;
2026-06-02 00:05:03 +09:00
const baseColor = getTerrainSurfaceColorAt ( item . x , item . y ) ;
2026-06-02 02:35:22 +09:00
landStepParticles . push ( {
x : item . x + ( Math . random ( ) - 0.5 ) * 0.16 ,
y : item . y + 0.16 + ( Math . random ( ) - 0.5 ) * 0.05 ,
vx : ( Math . random ( ) - 0.5 ) * 0.04 - ( item . vx || 0 ) * 0.01 ,
vy : - 0.014 - Math . random ( ) * 0.018 ,
started : time ,
life : 240 + Math . random ( ) * 140 ,
color : makeStepParticleColor ( baseColor , tile ? . type || 'grass' ) ,
tileType : tile ? . type || 'grass'
} ) ;
2026-06-02 00:05:03 +09:00
}
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 ( ) ;
}
2026-06-02 02:35:22 +09:00
function spawnConfiguredParticleEmitters ( items , time ) {
if ( view . zoom < PHASE5 _GUARDRAILS . particleMinZoom ) return ;
if ( natureDriftParticles . length >= PHASE5 _GUARDRAILS . maxParticles ) return ;
for ( const item of items ) {
2026-06-02 16:26:44 +09:00
const legacyEmitters = item . asset . meta ? . particlePixels || [ ] ;
if ( ! legacyEmitters . length ) continue ;
2026-06-02 00:05:03 +09:00
const info = getSpriteDrawInfo ( item , time , false ) ;
2026-06-02 02:35:22 +09:00
const scale = item . asset . category === 'static' ? STATIC _SCALE : DYNAMIC _SCALE ;
2026-06-02 16:26:44 +09:00
for ( const emitter of legacyEmitters ) {
if ( natureDriftParticles . length >= PHASE5 _GUARDRAILS . maxParticles || Math . random ( ) > 0.012 ) continue ;
2026-06-02 02:35:22 +09:00
const ex = item . asset . category === 'dynamic' && info . side === 'left' ? item . asset . size - 1 - emitter . x : emitter . x ;
const wx = info . drawX + ( ex + 0.5 ) * scale ;
const wy = info . drawY + ( emitter . y + 0.5 ) * scale ;
2026-06-02 16:26:44 +09:00
const velocity = particleVelocityForDirection ( emitter . dir || 'up' ) ;
2026-06-02 02:35:22 +09:00
natureDriftParticles . push ( {
x : wx + ( Math . random ( ) - 0.5 ) * 4 ,
y : wy + ( Math . random ( ) - 0.5 ) * 4 ,
2026-06-02 16:26:44 +09:00
vx : velocity . vx + ( Math . random ( ) - 0.5 ) * 4 ,
vy : velocity . vy + ( Math . random ( ) - 0.5 ) * 4 ,
gravity : 4 ,
2026-06-02 02:35:22 +09:00
started : time ,
life : 1500 + Math . random ( ) * 1600 ,
color : colorToHex ( emitter . c || selectedColorCode ) ,
sway : Math . random ( ) * Math . PI * 2
} ) ;
}
2026-06-02 00:05:03 +09:00
}
}
2026-06-02 16:26:44 +09:00
function particleVelocityForDirection ( dir ) {
const speed = 13 ;
if ( dir === 'down' ) return { vx : 0 , vy : speed } ;
if ( dir === 'left' ) return { vx : - speed , vy : 0 } ;
if ( dir === 'right' ) return { vx : speed , vy : 0 } ;
return { vx : 0 , vy : - speed } ;
}
2026-06-02 00:05:03 +09:00
function drawNatureDriftParticles ( time ) {
2026-06-02 02:35:22 +09:00
if ( ! natureDriftParticles . length || view . zoom < PHASE5 _GUARDRAILS . particleMinZoom ) return ;
2026-06-02 00:05:03 +09:00
ctx . save ( ) ;
for ( const particle of natureDriftParticles ) {
const age = ( time - particle . started ) / particle . life ;
if ( age < 0 || age > 1 ) continue ;
2026-06-02 02:35:22 +09:00
const seconds = ( time - particle . started ) / 1000 ;
const sway = Math . sin ( age * 5.5 + particle . sway ) * 3.5 ;
const x = particle . x + particle . vx * seconds + sway ;
2026-06-02 16:26:44 +09:00
const y = particle . y + particle . vy * seconds + age * ( particle . gravity ? ? 5 ) ;
2026-06-02 00:05:03 +09:00
ctx . globalAlpha = ( 1 - age ) * 0.72 ;
ctx . fillStyle = particle . color ;
2026-06-02 02:35:22 +09:00
ctx . fillRect ( Math . round ( x ) , Math . round ( y ) , 1 , 1 ) ;
2026-06-01 18:27:25 +09:00
}
2026-06-02 00:05:03 +09:00
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 ( ) ;
2026-06-01 18:27:25 +09:00
}
function drawFishBubbles ( x , y , time , seed ) {
const cycle = Math . floor ( ( time + seed * 37 ) / 1300 ) ;
if ( pseudoNoise ( cycle + seed ) < 0.55 ) return ;
const t = ( ( time + seed * 37 ) % 1300 ) / 1300 ;
ctx . save ( ) ;
ctx . fillStyle = 'rgba(225, 252, 255, .58)' ;
ctx . globalAlpha = Math . max ( 0 , 1 - t ) ;
for ( let i = 0 ; i < 4 ; i ++ ) {
const ox = Math . round ( ( pseudoNoise ( cycle + seed + i * 4.1 ) - . 5 ) * 7 ) ;
const oy = Math . round ( - t * 12 - i * 3 ) ;
ctx . fillRect ( Math . round ( x + ox ) , Math . round ( y + oy ) , 1 , 1 ) ;
}
ctx . restore ( ) ;
}
2026-06-02 16:26:44 +09:00
function drawSpriteShadow ( info , phase ) {
const { drawX , sprite , pos , anchorY , bob } = info ;
2026-06-01 21:17:09 +09:00
const shadow = phase ? . shadow || { dirX : 0 , length : 1 , skewX : 0 , scaleY : 0.28 , alpha : . 16 } ;
2026-06-02 16:26:44 +09:00
if ( ( shadow . alpha || 0 ) <= 0.001 ) return ;
2026-06-01 18:27:25 +09:00
const silhouette = getShadowCanvas ( sprite ) ;
2026-06-01 21:17:09 +09:00
const contactX = drawX + sprite . width / 2 ;
2026-06-02 16:26:44 +09:00
// 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 ) ) ;
2026-06-01 21:17:09 +09:00
2026-06-01 18:27:25 +09:00
ctx . save ( ) ;
ctx . globalAlpha = shadow . alpha ;
2026-06-02 16:26:44 +09:00
ctx . translate ( contactX , groundY ) ;
2026-06-01 21:17:09 +09:00
ctx . transform ( 1 + shadow . length * 0.08 , 0 , shadow . skewX , shadow . scaleY , 0 , 0 ) ;
2026-06-02 16:26:44 +09:00
ctx . drawImage ( silhouette , Math . round ( - sprite . width / 2 ) , Math . round ( mirrorOffset ) ) ;
2026-06-01 18:27:25 +09:00
ctx . restore ( ) ;
}
function getShadowCanvas ( sprite ) {
if ( shadowCanvasCache . has ( sprite ) ) return shadowCanvasCache . get ( sprite ) ;
const canvas = document . createElement ( 'canvas' ) ;
canvas . width = sprite . width ;
canvas . height = sprite . height ;
const c = canvas . getContext ( '2d' ) ;
c . imageSmoothingEnabled = false ;
2026-06-01 21:17:09 +09:00
c . save ( ) ;
c . translate ( 0 , sprite . height ) ;
c . scale ( 1 , - 1 ) ;
2026-06-01 18:27:25 +09:00
c . drawImage ( sprite , 0 , 0 ) ;
2026-06-01 21:17:09 +09:00
c . restore ( ) ;
2026-06-01 18:27:25 +09:00
c . globalCompositeOperation = 'source-in' ;
c . fillStyle = '#1b1f26' ;
c . fillRect ( 0 , 0 , canvas . width , canvas . height ) ;
shadowCanvasCache . set ( sprite , canvas ) ;
return canvas ;
}
function currentVoterKey ( ) {
2026-06-02 02:35:22 +09:00
return String ( state . account ? . id || state . authorName || els . authorName ? . value || 'Local Artist' ) . trim ( ) . toLowerCase ( ) ;
2026-06-01 18:27:25 +09:00
}
function getObjectVoteCounts ( objectId ) {
state . objectVotes || = { } ;
const votes = state . objectVotes [ objectId ] || { up : 0 , down : 0 , voters : { } } ;
votes . voters || = { } ;
return votes ;
}
function getAssetVoteCounts ( assetId ) {
state . assetVotes || = { } ;
const votes = state . assetVotes [ assetId ] || { up : 0 , down : 0 , voters : { } } ;
votes . voters || = { } ;
return votes ;
}
function voteAsset ( assetId , delta ) {
state . assetVotes || = { } ;
const votes = state . assetVotes [ assetId ] || { up : 0 , down : 0 , voters : { } } ;
votes . voters || = { } ;
const voter = currentVoterKey ( ) ;
const previous = votes . voters [ voter ] || 0 ;
if ( previous > 0 ) votes . up = Math . max ( 0 , votes . up - 1 ) ;
if ( previous < 0 ) votes . down = Math . max ( 0 , votes . down - 1 ) ;
if ( previous === delta ) delete votes . voters [ voter ] ;
else {
if ( delta > 0 ) votes . up += 1 ;
else votes . down += 1 ;
votes . voters [ voter ] = delta ;
}
state . assetVotes [ assetId ] = votes ;
saveState ( ) ;
renderLibrary ( ) ;
}
function hideAsset ( assetId ) {
state . hiddenAssets || = { } ;
state . hiddenAssets [ assetId ] = true ;
if ( selectedAssetId === assetId ) selectedAssetId = state . assets . find ( ( a ) => ! state . hiddenAssets ? . [ a . id ] ) ? . id || null ;
if ( selectedObject ? . assetId === assetId ) selectedObject = null ;
saveState ( ) ;
renderLibrary ( ) ;
updateSelectionBubble ( performance . now ( ) ) ;
toast ( 'Asset hidden locally.' ) ;
}
function getRemixCount ( assetId ) {
return state . assets . filter ( ( asset ) => asset . parentAssetId === assetId || asset . originalAssetId === assetId ) . length ;
}
function countPixels ( pixels ) {
return Array . isArray ( pixels ) ? pixels . filter ( Boolean ) . length : normalizePixels ( pixels || [ ] , editorSize ) . filter ( Boolean ) . length ;
}
function getSelectedWorldPosition ( ) {
if ( ! selectedObject ) return null ;
const asset = findAsset ( selectedObject . assetId ) ;
if ( ! asset ) return null ;
if ( selectedObject . kind === 'static' ) {
const placed = state . placed . find ( ( p ) => p . id === selectedObject . id ) ;
if ( ! placed ) return null ;
const pos = tileToWorld ( placed . x + . 5 , placed . y + . 5 ) ;
pos . y -= getLiftAtCoord ( placed . x + . 5 , placed . y + . 5 ) ;
return { pos , asset , objectId : placed . id } ;
}
const runtime = dynamicRuntime . find ( ( r ) => r . id === selectedObject . id ) ;
const summon = state . dynamicSummons . find ( ( s ) => s . id === selectedObject . id ) ;
const source = runtime || ( summon ? { x : summon . homeX + . 5 , y : summon . homeY + . 5 } : null ) ;
if ( ! source ) return null ;
const pos = tileToWorld ( source . x , source . y ) ;
if ( asset . subtype !== 'bird' ) pos . y -= getLiftAtCoord ( source . x , source . y ) ;
return { pos , asset , objectId : selectedObject . id } ;
}
2026-06-02 16:26:44 +09:00
function findRemixSourceObject ( asset ) {
const ids = [ asset ? . parentAssetId , asset ? . originalAssetId ] . filter ( Boolean ) ;
for ( const assetId of ids ) {
const placed = ( state . placed || [ ] ) . find ( ( p ) => p . assetId === assetId && ! state . hiddenObjects ? . [ p . id ] ) ;
if ( placed ) return { kind : 'static' , object : placed , asset : findAsset ( assetId ) , x : placed . x + . 5 , y : placed . y + . 5 } ;
const summon = ( state . dynamicSummons || [ ] ) . find ( ( p ) => p . assetId === assetId && ! state . hiddenObjects ? . [ p . id ] ) ;
if ( summon ) return { kind : 'dynamic' , object : summon , asset : findAsset ( assetId ) , x : summon . homeX + . 5 , y : summon . homeY + . 5 } ;
}
return null ;
}
function centerViewOnWorldCoord ( x , y , zoom = null ) {
const pos = tileToWorld ( x , y ) ;
pos . y -= getLiftAtCoord ( x , y ) ;
if ( zoom != null ) view . zoom = clamp ( zoom , MIN _ZOOM , MAX _ZOOM ) ;
view . x = cw / 2 - pos . x * view . zoom ;
view . y = ch / 2 - ( pos . y - 32 ) * view . zoom ;
render ( performance . now ( ) ) ;
}
function teleportToRemixSource ( ) {
if ( ! selectedObject ) return ;
const asset = findAsset ( selectedObject . assetId ) ;
const source = findRemixSourceObject ( asset ) ;
if ( ! source || ! source . object || ! source . asset ) {
toast ( 'Remix source is not placed on the island right now.' ) ;
return ;
}
selectWorldObject ( source . kind , source . object . id , source . asset . id , performance . now ( ) ) ;
centerViewOnWorldCoord ( source . x , source . y , Math . max ( view . zoom , 1.05 ) ) ;
toast ( ` Teleported to ${ source . asset . name || 'remix source' } . ` ) ;
}
function toggleBubbleMenu ( ) {
if ( ! els . bubbleMenuActions ) return ;
els . bubbleMenuActions . hidden = ! els . bubbleMenuActions . hidden ;
}
function getSelectedDrawableItem ( time = performance . now ( ) ) {
if ( ! selectedObject ) return null ;
const items = getDrawableItems ( time , getViewportWorldRect ( 512 ) ) ;
return items . find ( ( item ) => item . source ? . id === selectedObject . id && item . asset ? . id === selectedObject . assetId ) || null ;
}
function getSelectedBubbleAnchor ( time = performance . now ( ) ) {
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
} ;
}
2026-06-01 18:27:25 +09:00
function updateSelectionBubble ( time ) {
if ( ! els . selectionBubble ) return ;
const selected = getSelectedWorldPosition ( ) ;
if ( ! selected ) {
els . selectionBubble . hidden = true ;
2026-06-02 16:26:44 +09:00
if ( els . bubbleMenuActions ) els . bubbleMenuActions . hidden = true ;
2026-06-01 18:27:25 +09:00
return ;
}
2026-06-02 16:26:44 +09:00
const anchor = getSelectedBubbleAnchor ( time ) || { x : selected . pos . x , y : selected . pos . y - Math . max ( 48 , ( selected . asset . size || 8 ) * 2 ) , asset : selected . asset , objectId : selected . objectId } ;
const { asset , objectId } = anchor ;
const sx = anchor . x * view . zoom + view . x ;
const sy = anchor . y * view . zoom + view . y ;
if ( sx < - 120 || sy < - 160 || sx > cw + 120 || sy > ch + 120 ) {
2026-06-01 18:27:25 +09:00
els . selectionBubble . hidden = true ;
2026-06-02 16:26:44 +09:00
if ( els . bubbleMenuActions ) els . bubbleMenuActions . hidden = true ;
2026-06-01 18:27:25 +09:00
return ;
}
els . selectionBubble . hidden = false ;
2026-06-02 16:26:44 +09:00
const bubbleX = clamp ( sx , 96 , cw - 96 ) ;
const bubbleY = clamp ( sy , 28 , ch - 118 ) ;
els . selectionBubble . style . left = ` ${ Math . round ( bubbleX ) } px ` ;
els . selectionBubble . style . top = ` ${ Math . round ( bubbleY ) } px ` ;
els . selectionBubble . style . transform = 'translate(-50%, -100%)' ;
2026-06-01 18:27:25 +09:00
els . bubbleName . textContent = asset . name || 'Untitled' ;
els . bubbleAuthor . textContent = ` by ${ asset . author || 'Local Artist' } ` ;
const parent = asset . parentAssetId ? findAsset ( asset . parentAssetId ) : null ;
2026-06-02 16:26:44 +09:00
const remixSource = findRemixSourceObject ( asset ) ;
2026-06-01 18:27:25 +09:00
if ( els . bubbleRemixFrom ) {
els . bubbleRemixFrom . hidden = ! parent ;
els . bubbleRemixFrom . textContent = parent ? ` Remix of: ${ parent . name || 'Untitled' } ` : '' ;
}
2026-06-02 16:26:44 +09:00
if ( els . bubbleTeleport ) {
els . bubbleTeleport . hidden = ! remixSource ;
els . bubbleTeleport . textContent = remixSource ? . asset ? ` Teleport to ${ remixSource . asset . name || 'source' } ` : 'Teleport to source' ;
}
2026-06-01 18:27:25 +09:00
if ( els . bubbleRemixCount ) els . bubbleRemixCount . textContent = ` Remixed: ${ getRemixCount ( asset . id ) } ` ;
const votes = getObjectVoteCounts ( objectId ) ;
const voter = currentVoterKey ( ) ;
const previous = votes . voters ? . [ voter ] || 0 ;
els . voteScore . textContent = ` ${ votes . up - votes . down } ` ;
els . voteUp . classList . toggle ( 'activeVote' , previous > 0 ) ;
els . voteDown . classList . toggle ( 'activeVote' , previous < 0 ) ;
els . voteUp . classList . toggle ( 'mutedVote' , previous < 0 ) ;
els . voteDown . classList . toggle ( 'mutedVote' , previous > 0 ) ;
if ( els . bubbleHide ) els . bubbleHide . hidden = previous >= 0 ;
2026-06-02 02:35:22 +09:00
if ( els . bubbleEdit ) {
const mine = ( asset . author || 'Local Artist' ) === ( state . authorName || 'Local Artist' ) ;
els . bubbleEdit . hidden = ! mine ;
}
2026-06-02 00:05:03 +09:00
if ( els . bubbleReport ) {
const reported = hasReportForObject ( objectId ) ;
els . bubbleReport . disabled = reported ;
els . bubbleReport . textContent = reported ? 'Reported' : 'Report' ;
}
2026-06-01 18:27:25 +09:00
}
function voteSelected ( delta ) {
if ( ! selectedObject ) return ;
state . objectVotes || = { } ;
const key = selectedObject . id ;
const votes = state . objectVotes [ key ] || { up : 0 , down : 0 , voters : { } } ;
votes . voters || = { } ;
const voter = currentVoterKey ( ) ;
const previous = votes . voters [ voter ] || 0 ;
if ( previous > 0 ) votes . up = Math . max ( 0 , votes . up - 1 ) ;
if ( previous < 0 ) votes . down = Math . max ( 0 , votes . down - 1 ) ;
if ( previous === delta ) {
delete votes . voters [ voter ] ;
} else {
if ( delta > 0 ) {
votes . up += 1 ;
spawnConfettiAtSelection ( performance . now ( ) ) ;
} else {
votes . down += 1 ;
}
votes . voters [ voter ] = delta ;
}
state . objectVotes [ key ] = votes ;
saveState ( ) ;
updateSelectionBubble ( performance . now ( ) ) ;
renderLibrary ( ) ;
2026-06-02 02:35:22 +09:00
updateRotationStats ( ) ;
2026-06-01 18:27:25 +09:00
// Server moderation TODO: when online, hide or delete objects from the server if downvotes exceed a threshold
// such as down >= 5 and down - up >= 3. Do not implement server deletion in this local prototype.
}
function remixSelected ( ) {
if ( ! selectedObject ) return ;
const asset = findAsset ( selectedObject . assetId ) ;
2026-06-02 02:35:22 +09:00
if ( asset ) remixEdit ( asset ) ;
2026-06-01 18:27:25 +09:00
}
2026-06-02 02:35:22 +09:00
function editSelectedOriginal ( ) {
if ( ! selectedObject ) return ;
const asset = findAsset ( selectedObject . assetId ) ;
if ( ! asset ) return ;
const mine = ( asset . author || 'Local Artist' ) === ( state . authorName || 'Local Artist' ) ;
if ( ! mine ) {
toast ( 'Only your own assets can be edited directly. Use Remix instead.' ) ;
return ;
}
editOriginalAsset ( asset ) ;
}
2026-06-01 18:27:25 +09:00
function hideSelected ( ) {
if ( ! selectedObject ) return ;
state . hiddenObjects || = { } ;
state . hiddenObjects [ selectedObject . id ] = true ;
const assetId = selectedObject . assetId ;
selectedObject = null ;
saveState ( ) ;
updateSelectionBubble ( performance . now ( ) ) ;
renderLibrary ( ) ;
toast ( 'Hidden locally.' ) ;
// Server moderation TODO: when synced, repeated low-rating + hide actions can flag this object for review.
}
2026-06-02 00:05:03 +09:00
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.' ) ;
}
2026-06-01 18:27:25 +09:00
function spawnFishBubbleCluster ( tileX , tileY , time , seed = 0 ) {
const count = 2 + Math . floor ( pseudoNoise ( time * 0.003 + seed ) * 3 ) ;
for ( let i = 0 ; i < count ; i ++ ) {
bubbleParticles . push ( {
x : tileX + ( pseudoNoise ( seed + time * . 01 + i * 7.1 ) - . 5 ) * . 22 ,
y : tileY + ( pseudoNoise ( seed + time * . 013 + i * 5.7 ) - . 5 ) * . 22 ,
started : time + i * 45 ,
life : 900 + pseudoNoise ( seed + i * 3.3 ) * 550 ,
drift : ( pseudoNoise ( seed + i * 9.9 ) - . 5 ) * . 08
} ) ;
}
}
function drawBubbleParticles ( time ) {
if ( ! bubbleParticles . length ) return ;
ctx . save ( ) ;
ctx . fillStyle = 'rgba(225, 252, 255, .62)' ;
for ( const particle of bubbleParticles ) {
const age = ( time - particle . started ) / particle . life ;
if ( age < 0 || age > 1 ) continue ;
const pos = tileToWorld ( particle . x + particle . drift * age , particle . y ) ;
pos . y -= 3 + age * 18 ;
ctx . globalAlpha = 1 - age ;
ctx . fillRect ( Math . round ( pos . x ) , Math . round ( pos . y ) , 1 , 1 ) ;
}
ctx . restore ( ) ;
}
function spawnConfettiAtSelection ( time ) {
const selected = getSelectedWorldPosition ( ) ;
if ( ! selected ) return ;
const palette = [ 'a' , 'f' , 'l' , 's' , 'A' , 'H' , 'P' , 'Z' ] ;
for ( let i = 0 ; i < 22 ; i ++ ) {
const angle = ( Math . PI * 2 * i ) / 22 + pseudoNoise ( time + i ) * . 4 ;
const speed = 18 + pseudoNoise ( i * 4.7 + time ) * 34 ;
confettiParticles . push ( {
x : selected . pos . x ,
y : selected . pos . y - selected . asset . size * ( selected . asset . category === 'static' ? STATIC _SCALE : DYNAMIC _SCALE ) - 8 ,
vx : Math . cos ( angle ) * speed ,
vy : Math . sin ( angle ) * speed - 25 ,
color : palette [ i % palette . length ] ,
started : time ,
life : 850 + pseudoNoise ( i + time ) * 450
} ) ;
}
}
function drawConfettiParticles ( time ) {
if ( ! confettiParticles . length ) return ;
ctx . save ( ) ;
for ( const particle of confettiParticles ) {
const ageMs = time - particle . started ;
const t = ageMs / particle . life ;
if ( t < 0 || t > 1 ) continue ;
const seconds = ageMs / 1000 ;
const x = particle . x + particle . vx * seconds ;
const y = particle . y + particle . vy * seconds + 48 * seconds * seconds ;
ctx . globalAlpha = 1 - t ;
ctx . fillStyle = colorToHex ( particle . color ) ;
ctx . fillRect ( Math . round ( x ) , Math . round ( y ) , 3 , 3 ) ;
}
ctx . restore ( ) ;
}
2026-06-02 16:26:44 +09:00
function drawLightSources ( sources , darkness , time = performance . now ( ) ) {
2026-06-01 18:27:25 +09:00
ctx . save ( ) ;
for ( const source of sources ) {
const sx = source . x * view . zoom + view . x ;
const sy = source . y * view . zoom + view . y ;
2026-06-02 16:26:44 +09:00
const flicker = 0.94 + pseudoNoise ( ( time * 0.0017 ) + ( source . flickerSeed || 0 ) ) * 0.12 ;
const radius = source . radius * view . zoom * flicker ;
const intensity = 0.68 + ( flicker - 0.94 ) * 0.28 ;
const auraRadius = radius * 1.12 ;
2026-06-02 00:05:03 +09:00
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 ) ) ;
2026-06-02 16:26:44 +09:00
core . addColorStop ( 0 , hexToRgba ( source . color , . 48 * intensity ) ) ;
core . addColorStop ( . 34 , hexToRgba ( source . color , . 22 * intensity ) ) ;
2026-06-02 00:05:03 +09:00
core . addColorStop ( 1 , hexToRgba ( source . color , . 00 ) ) ;
ctx . fillStyle = core ;
2026-06-01 18:27:25 +09:00
ctx . beginPath ( ) ;
2026-06-02 00:05:03 +09:00
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 ) ;
2026-06-02 16:26:44 +09:00
glow . addColorStop ( 0 , hexToRgba ( source . color , . 13 * intensity ) ) ;
glow . addColorStop ( . 42 , hexToRgba ( source . color , . 05 * intensity ) ) ;
2026-06-02 00:05:03 +09:00
glow . addColorStop ( 1 , 'rgba(255, 255, 255, 0)' ) ;
ctx . fillStyle = glow ;
ctx . beginPath ( ) ;
ctx . arc ( sx , sy , auraRadius , 0 , Math . PI * 2 ) ;
2026-06-01 18:27:25 +09:00
ctx . fill ( ) ;
}
ctx . restore ( ) ;
}
function getSpriteCanvas ( asset , side ) {
2026-06-02 16:26:44 +09:00
const lightsOn = areNightLightsActive ( renderPhase ) ;
const phaseKey = renderPhase ? ` ${ renderPhase . key } : ${ Math . round ( ( renderPhase . darkness || 0 ) * 10 ) } : ${ lightsOn ? 'lit' : 'unlit' } ` : 'day:unlit' ;
2026-06-02 00:05:03 +09:00
const key = ` ${ asset . id } : ${ side } : ${ asset . category === 'static' ? STATIC _SCALE : DYNAMIC _SCALE } : ${ phaseKey } ` ;
2026-06-01 18:27:25 +09:00
if ( spriteCache . has ( key ) ) return spriteCache . get ( key ) ;
const pixels = getAssetPixels ( asset , side ) ;
2026-06-02 16:26:44 +09:00
const rawDepths = normalizeDepthPixels ( asset . meta ? . depthPixels || [ ] , asset . size || 1 ) ;
const depths = asset . category === 'dynamic' && side === 'left' ? mirrorDepthPixels ( rawDepths , asset . size ) : rawDepths ;
const lights = lightsOn ? getAssetLightPointsForSide ( asset , side ) : [ ] ;
2026-06-01 18:27:25 +09:00
const scale = asset . category === 'static' ? STATIC _SCALE : DYNAMIC _SCALE ;
const canvas = document . createElement ( 'canvas' ) ;
canvas . width = asset . size * scale ;
canvas . height = asset . size * scale ;
const c = canvas . getContext ( '2d' ) ;
c . imageSmoothingEnabled = false ;
for ( let y = 0 ; y < asset . size ; y ++ ) {
for ( let x = 0 ; x < asset . size ; x ++ ) {
const color = pixels [ y * asset . size + x ] ;
if ( ! color ) continue ;
2026-06-02 16:26:44 +09:00
const depth = depths [ y * asset . size + x ] || 0 ;
c . fillStyle = shadeAssetPixelColor ( colorToHex ( color ) , depth , x , y , asset . size , renderPhase , pixels , depths , lights ) ;
2026-06-01 18:27:25 +09:00
c . fillRect ( x * scale , y * scale , scale , scale ) ;
}
}
spriteCache . set ( key , canvas ) ;
return canvas ;
}
function getAssetPixels ( asset , side = 'right' ) {
2026-06-01 22:48:15 +09:00
const right = normalizePixels ( asset . faces ? . right || asset . pixels || blankPixels ( asset . size ) , asset . size ) ;
if ( asset . category === 'dynamic' && side === 'left' ) return mirrorPixels ( right , asset . size ) ;
return right ;
2026-06-01 18:27:25 +09:00
}
function makeWorld ( ) {
const islands = [
{ cx : 36 , cy : 34 , rx : 32 , ry : 22 , height : 1.04 } ,
{ cx : 92 , cy : 30 , rx : 34 , ry : 20 , height : . 98 } ,
{ cx : 74 , cy : 76 , rx : 42 , ry : 24 , height : 1.02 } ,
{ cx : 124 , cy : 72 , rx : 16 , ry : 14 , height : . 92 } ,
{ cx : 26 , cy : 86 , rx : 16 , ry : 12 , height : . 9 } ,
{ cx : 118 , cy : 102 , rx : 20 , ry : 9 , height : . 86 }
] ;
const tiles = [ ] ;
for ( let y = 0 ; y < WORLD _H ; y ++ ) {
for ( let x = 0 ; x < WORLD _W ; x ++ ) {
const continent = fractalPerlin ( x * . 035 , y * . 035 , 4 ) ;
const detail = fractalPerlin ( x * . 095 + 41.7 , y * . 095 - 13.2 , 3 ) ;
let score = - 1.2 ;
for ( const island of islands ) {
const warpX = ( fractalPerlin ( x * . 055 + island . cx , y * . 055 , 2 ) - . 5 ) * 7.0 ;
const warpY = ( fractalPerlin ( x * . 055 , y * . 055 + island . cy , 2 ) - . 5 ) * 5.0 ;
const dx = ( x + warpX - island . cx ) / island . rx ;
const dy = ( y + warpY - island . cy ) / island . ry ;
const base = 1 - ( dx * dx + dy * dy ) ;
score = Math . max ( score , base * island . height ) ;
}
score += ( continent - . 5 ) * . 36 + ( detail - . 5 ) * . 18 ;
let type = 'water' ;
if ( score > . 015 ) type = 'sand' ;
if ( score > . 17 ) type = 'grass' ;
if ( score > . 56 && fractalPerlin ( x * . 16 + 8 , y * . 16 + 4 , 2 ) > . 56 ) type = 'highland' ;
const heightLevel = type === 'highland' ? 1 : 0 ;
2026-06-01 21:17:09 +09:00
tiles . push ( { x , y , type , heightLevel , shade : fractalPerlin ( x * . 31 + 9 , y * . 31 - 2 , 2 ) - . 5 } ) ;
2026-06-01 18:27:25 +09:00
}
}
return {
tiles ,
get ( x , y ) {
if ( x < 0 || y < 0 || x >= WORLD _W || y >= WORLD _H ) return null ;
return tiles [ y * WORLD _W + x ] ;
}
} ;
}
function buildTerrainCache ( worldData ) {
2026-06-01 22:48:15 +09:00
const chunkSize = TERRAIN _CHUNK _SIZE ;
const chunks = [ ] ;
let minX = Infinity , minY = Infinity , maxX = - Infinity , maxY = - Infinity ;
for ( let cy = 0 ; cy < WORLD _H ; cy += chunkSize ) {
for ( let cx = 0 ; cx < WORLD _W ; cx += chunkSize ) {
const tiles = [ ] ;
for ( let y = cy ; y < Math . min ( WORLD _H , cy + chunkSize ) ; y ++ ) {
for ( let x = cx ; x < Math . min ( WORLD _W , cx + chunkSize ) ; x ++ ) {
const tile = worldData . get ( x , y ) ;
if ( tile ) tiles . push ( tile ) ;
}
}
if ( ! tiles . length ) continue ;
const bounds = getTerrainChunkBounds ( tiles ) ;
const canvas = document . createElement ( 'canvas' ) ;
canvas . width = Math . ceil ( bounds . w ) ;
canvas . height = Math . ceil ( bounds . h ) ;
const c = canvas . getContext ( '2d' , { alpha : true } ) ;
c . imageSmoothingEnabled = false ;
c . save ( ) ;
c . translate ( - bounds . x , - bounds . y ) ;
for ( const tile of tiles ) drawTerrainTile ( c , tile , worldData ) ;
c . restore ( ) ;
chunks . push ( { canvas , x : bounds . x , y : bounds . y , w : canvas . width , h : canvas . height , tileX : cx , tileY : cy } ) ;
minX = Math . min ( minX , bounds . x ) ;
minY = Math . min ( minY , bounds . y ) ;
maxX = Math . max ( maxX , bounds . x + canvas . width ) ;
maxY = Math . max ( maxY , bounds . y + canvas . height ) ;
}
}
2026-06-02 00:05:03 +09:00
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 ;
2026-06-01 22:48:15 +09:00
}
function getTerrainChunkBounds ( tiles ) {
let minX = Infinity , minY = Infinity , maxX = - Infinity , maxY = - Infinity ;
for ( const tile of tiles ) {
const p = tileToWorld ( tile . x , tile . y ) ;
const lift = getTileLift ( tile ) ;
minX = Math . min ( minX , p . x - TILE _W / 2 - 3 ) ;
maxX = Math . max ( maxX , p . x + TILE _W / 2 + 3 ) ;
minY = Math . min ( minY , p . y - lift - 16 ) ;
maxY = Math . max ( maxY , p . y + TILE _H + 18 ) ;
}
minX = Math . floor ( minX ) ;
minY = Math . floor ( minY ) ;
maxX = Math . ceil ( maxX ) ;
maxY = Math . ceil ( maxY ) ;
return { x : minX , y : minY , w : Math . max ( 1 , maxX - minX ) , h : Math . max ( 1 , maxY - minY ) } ;
}
2026-06-01 18:27:25 +09:00
2026-06-01 22:48:15 +09:00
function drawTerrainCache ( cache ) {
const rect = getViewportWorldRect ( 64 ) ;
for ( const chunk of cache . chunks ) {
if ( chunk . x + chunk . w < rect . left || chunk . x > rect . right || chunk . y + chunk . h < rect . top || chunk . y > rect . bottom ) continue ;
ctx . drawImage ( chunk . canvas , chunk . x , chunk . y ) ;
2026-06-01 18:27:25 +09:00
}
}
2026-06-01 22:48:15 +09:00
2026-06-02 00:05:03 +09:00
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 ( ) ;
}
return canvas ;
}
2026-06-02 16:26:44 +09:00
function drawCoastalFoam ( time ) { return ;
/* disabled by request */
2026-06-02 00:05:03 +09:00
if ( ! terrainCache ? . coastTiles ? . length || ! coastalFoamTextures ) return ;
const rect = getViewportWorldRect ( 64 ) ;
2026-06-02 16:26:44 +09:00
const t = time * 0.001 ;
2026-06-02 00:05:03 +09:00
for ( const tile of terrainCache . coastTiles ) {
const pos = tileToWorld ( tile . x , tile . y ) ;
2026-06-02 16:26:44 +09:00
if ( pos . x + 28 < rect . left || pos . x - 28 > rect . right || pos . y + 24 < rect . top || pos . y - 24 > rect . bottom ) continue ;
const seed = ( tile . x * 17.17 + tile . y * 7.91 ) ;
const drift = Math . sin ( t * 2.4 + seed ) * 5.0 ;
const bob = Math . cos ( t * 2.9 + seed * . 7 ) * 2.1 ;
const crawl = Math . sin ( t * 1.25 + seed * . 41 ) * 3.4 ;
const pulse = 0.68 + Math . sin ( t * 3.1 + seed * . 31 ) * 0.32 ;
2026-06-02 00:05:03 +09:00
const baseY = pos . y + TILE _H * 0.42 ;
ctx . save ( ) ;
2026-06-02 16:26:44 +09:00
ctx . globalAlpha = 0.20 + 0.18 * pulse ;
ctx . translate ( pos . x + drift , baseY + bob + crawl * . 25 ) ;
ctx . rotate ( time / 9500 + ( tile . x + tile . y ) * 0.035 ) ;
ctx . scale ( 0.92 + pulse * 0.12 , 0.86 + pulse * 0.10 ) ;
2026-06-02 00:05:03 +09:00
ctx . drawImage ( coastalFoamTextures . a , - 12 , - 12 ) ;
ctx . restore ( ) ;
ctx . save ( ) ;
2026-06-02 16:26:44 +09:00
ctx . globalAlpha = 0.14 + 0.12 * ( 1 - pulse ) ;
ctx . translate ( pos . x - drift * 0.65 + crawl * . 35 , baseY + 1 - bob * 0.7 ) ;
ctx . rotate ( - ( time / 12500 ) + ( tile . x - tile . y ) * 0.03 ) ;
ctx . scale ( 0.78 + ( 1 - pulse ) * 0.18 , 0.78 + pulse * 0.12 ) ;
2026-06-02 00:05:03 +09:00
ctx . drawImage ( coastalFoamTextures . b , - 12 , - 12 ) ;
ctx . restore ( ) ;
}
}
2026-06-01 18:27:25 +09:00
function drawTerrainTile ( c , tile , worldData ) {
2026-06-01 21:17:09 +09:00
const { x , y } = tileToWorld ( tile . x , tile . y ) ;
2026-06-01 18:27:25 +09:00
const palette = {
water : [ '#6ebfe0' , '#69bbdc' ] ,
sand : [ '#ead99d' , '#e8d293' ] ,
grass : [ '#91cf76' , '#89c970' ] ,
highland : [ '#9db781' , '#95ab79' ]
} ;
const [ base , alt ] = palette [ tile . type ] ;
const fill = tile . shade > 0 ? base : alt ;
const lift = getTileLift ( tile ) ;
if ( tile . type === 'highland' ) {
const frontLeft = worldData . get ( tile . x , tile . y + 1 ) ;
const frontRight = worldData . get ( tile . x + 1 , tile . y ) ;
2026-06-01 21:17:09 +09:00
const leftLower = ! frontLeft || frontLeft . type !== 'highland' ;
const rightLower = ! frontRight || frontRight . type !== 'highland' ;
2026-06-01 18:27:25 +09:00
if ( rightLower ) {
c . fillStyle = '#7b8e6c' ;
c . beginPath ( ) ;
c . moveTo ( x , y + TILE _H - lift ) ;
c . lineTo ( x + TILE _W / 2 , y + TILE _H / 2 - lift ) ;
c . lineTo ( x + TILE _W / 2 , y + TILE _H / 2 ) ;
c . lineTo ( x , y + TILE _H ) ;
c . closePath ( ) ;
c . fill ( ) ;
}
if ( leftLower ) {
c . fillStyle = '#6f8263' ;
c . beginPath ( ) ;
c . moveTo ( x - TILE _W / 2 , y + TILE _H / 2 - lift ) ;
c . lineTo ( x , y + TILE _H - lift ) ;
c . lineTo ( x , y + TILE _H ) ;
c . lineTo ( x - TILE _W / 2 , y + TILE _H / 2 ) ;
c . closePath ( ) ;
c . fill ( ) ;
}
}
c . fillStyle = fill ;
c . beginPath ( ) ;
c . moveTo ( x , y - lift ) ;
c . lineTo ( x + TILE _W / 2 , y + TILE _H / 2 - lift ) ;
c . lineTo ( x , y + TILE _H - lift ) ;
c . lineTo ( x - TILE _W / 2 , y + TILE _H / 2 - lift ) ;
c . closePath ( ) ;
c . fill ( ) ;
if ( tile . type === 'water' ) {
c . fillStyle = 'rgba(255,255,255,.045)' ;
c . beginPath ( ) ;
c . moveTo ( x - TILE _W * . 20 , y + TILE _H * . 48 ) ;
c . lineTo ( x + TILE _W * . 15 , y + TILE _H * . 31 ) ;
c . lineTo ( x + TILE _W * . 25 , y + TILE _H * . 38 ) ;
c . lineTo ( x - TILE _W * . 10 , y + TILE _H * . 55 ) ;
c . closePath ( ) ;
c . fill ( ) ;
}
2026-06-01 21:17:09 +09:00
c . strokeStyle = 'rgba(36, 48, 68, .02)' ;
2026-06-01 18:27:25 +09:00
c . lineWidth = 1 ;
c . beginPath ( ) ;
c . moveTo ( x , y - lift ) ;
c . lineTo ( x + TILE _W / 2 , y + TILE _H / 2 - lift ) ;
c . lineTo ( x , y + TILE _H - lift ) ;
c . lineTo ( x - TILE _W / 2 , y + TILE _H / 2 - lift ) ;
c . closePath ( ) ;
c . stroke ( ) ;
}
function getTileLift ( tile ) {
return tile ? . type === 'highland' ? 12 : 0 ;
}
function getLiftAtCoord ( x , y ) {
const tile = world . get ( clamp ( Math . floor ( x ) , 0 , WORLD _W - 1 ) , clamp ( Math . floor ( y ) , 0 , WORLD _H - 1 ) ) ;
return getTileLift ( tile ) ;
}
2026-06-02 00:05:03 +09:00
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' ;
}
2026-06-02 02:35:22 +09:00
function makeStepParticleColor ( baseColor , tileType ) {
const presets = {
grass : [ '#d7f39a' , '#b7e36f' , '#f0cf61' , '#6fbf65' ] ,
sand : [ '#fff1ba' , '#f2c879' , '#ffffff' , '#d4a957' ] ,
highland : [ '#d5e8a4' , '#b4c77c' , '#ffffff' , '#7f9e6d' ] ,
water : [ '#d9f7ff' , '#a8e5ff' ]
} ;
const list = presets [ tileType ] || presets . grass ;
const chosen = Math . random ( ) < 0.72 ? list [ Math . floor ( Math . random ( ) * list . length ) ] : varyHexColor ( baseColor , 58 , 34 , 28 ) ;
return chosen ;
}
2026-06-01 18:27:25 +09:00
function loadState ( ) {
try {
const raw = localStorage . getItem ( STORAGE _KEY ) ;
if ( raw ) {
const parsed = JSON . parse ( raw ) ;
2026-06-01 22:48:15 +09:00
const expanded = Phase2Sync ? . isCompactState ? . ( parsed ) ? Phase2Sync . expandState ( parsed ) : parsed ;
if ( expanded && Array . isArray ( expanded . assets ) ) return normalizeState ( expanded ) ;
2026-06-01 18:27:25 +09:00
}
} catch ( error ) {
console . warn ( 'Could not load local state.' , error ) ;
}
return seedState ( ) ;
}
function saveState ( ) {
2026-06-01 21:17:09 +09:00
state . schema = SAVE _SCHEMA ;
2026-06-02 00:05:03 +09:00
state . guardrails = { ... PHASE5 _GUARDRAILS , ... ( state . guardrails || { } ) } ;
state . moderationReports = normalizeModerationReports ( state . moderationReports || [ ] ) ;
state . settings = { ... defaultVisualSettings ( ) , ... ( state . settings || { } ) } ;
2026-06-01 22:48:15 +09:00
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 ( ) ;
2026-06-02 00:05:03 +09:00
updateGuardrailStats ( ) ;
2026-06-02 02:35:22 +09:00
updateRotationStats ( ) ;
2026-06-01 18:27:25 +09:00
}
function normalizeState ( input ) {
2026-06-01 21:17:09 +09:00
return {
schema : SAVE _SCHEMA ,
2026-06-01 18:27:25 +09:00
authorName : input . authorName || 'Local Artist' ,
2026-06-01 21:17:09 +09:00
assets : Array . isArray ( input . assets ) ? input . assets . map ( normalizeAsset ) : [ ] ,
2026-06-01 22:48:15 +09:00
placed : normalizePlacements ( Array . isArray ( input . placed ) ? input . placed : [ ] ) ,
dynamicSummons : normalizeDynamicSummons ( Array . isArray ( input . dynamicSummons ) ? input . dynamicSummons : [ ] ) ,
2026-06-01 18:27:25 +09:00
objectVotes : input . objectVotes || { } ,
assetVotes : input . assetVotes || { } ,
hiddenAssets : input . hiddenAssets || { } ,
2026-06-01 22:48:15 +09:00
hiddenObjects : input . hiddenObjects || { } ,
2026-06-02 00:05:03 +09:00
moderationReports : normalizeModerationReports ( input . moderationReports || [ ] ) ,
guardrails : { ... PHASE5 _GUARDRAILS , ... ( input . guardrails || { } ) } ,
2026-06-01 22:48:15 +09:00
eventLog : Array . isArray ( input . eventLog ) ? input . eventLog . slice ( - ( Phase2Sync ? . EVENT _LOG _LIMIT || 300 ) ) : [ ] ,
2026-06-02 00:05:03 +09:00
sync : input . sync || { lastEventId : null } ,
2026-06-02 02:35:22 +09:00
settings : { ... defaultVisualSettings ( ) , ... ( input . settings || { } ) } ,
2026-06-02 16:26:44 +09:00
account : normalizeAccount ( input . account ) ,
2026-06-02 02:35:22 +09:00
publishLog : Array . isArray ( input . publishLog ) ? input . publishLog . slice ( - 300 ) : [ ]
2026-06-01 18:27:25 +09:00
} ;
}
function normalizeAsset ( asset ) {
const size = Number ( asset . size ) || 16 ;
const category = asset . category === 'dynamic' ? 'dynamic' : 'static' ;
2026-06-01 22:48:15 +09:00
const right = normalizePixels ( asset . faces ? . right || asset . pixels || blankPixels ( size ) , size ) ;
const subtype = asset . subtype || ( category === 'dynamic' ? 'human' : 'other' ) ;
const normalized = {
2026-06-01 18:27:25 +09:00
id : asset . id || uid ( ) ,
name : asset . name || 'Untitled' ,
category ,
2026-06-01 22:48:15 +09:00
subtype ,
2026-06-01 18:27:25 +09:00
size ,
2026-06-01 22:48:15 +09:00
pixels : encodePixels ( right ) ,
faces : category === 'dynamic' ? { right : encodePixels ( right ) , left : 'mirror' } : null ,
2026-06-01 18:27:25 +09:00
parentAssetId : asset . parentAssetId || null ,
originalAssetId : asset . originalAssetId || null ,
createdAt : asset . createdAt || Date . now ( ) ,
2026-06-01 22:48:15 +09:00
updatedAt : asset . updatedAt || asset . createdAt || Date . now ( ) ,
2026-06-01 18:27:25 +09:00
author : asset . author || 'Local Artist' ,
2026-06-01 22:48:15 +09:00
meta : normalizeAssetMeta ( asset . meta || { } , category , subtype , size )
} ;
normalized . contentHash = computeAssetContentHash ( normalized ) ;
return normalized ;
}
function normalizeAssetMeta ( meta , category , subtype , size ) {
const normalizedDepth = normalizeDepthPixels ( meta . depthPixels || [ ] , size ) ;
const lightPixels = Array . isArray ( meta . lightPixels )
? meta . lightPixels
. map ( ( p ) => ( { x : clampInt ( p . x , 0 , size - 1 , 0 ) , y : clampInt ( p . y , 0 , size - 1 , 0 ) , c : p . c || meta . lightColor || nearestPaletteCode ( '#ffd86a' ) } ) )
. filter ( ( p ) => Number . isFinite ( p . x ) && Number . isFinite ( p . y ) )
: [ ] ;
2026-06-02 16:26:44 +09:00
const legacyParticlePixels = Array . isArray ( meta . particlePixels )
2026-06-02 02:35:22 +09:00
? meta . particlePixels
2026-06-02 16:26:44 +09:00
. map ( ( p ) => ( { x : clampInt ( p . x , 0 , size - 1 , 0 ) , y : clampInt ( p . y , 0 , size - 1 , 0 ) , c : p . c || nearestPaletteCode ( '#ffffff' ) , dir : p . dir || 'up' } ) )
2026-06-02 02:35:22 +09:00
. filter ( ( p ) => Number . isFinite ( p . x ) && Number . isFinite ( p . y ) )
: [ ] ;
2026-06-02 16:26:44 +09:00
return buildAssetMeta ( category , subtype , normalizedDepth , lightPixels , meta . lightColor || '#ffd86a' , meta . door || null , size , legacyParticlePixels ) ;
2026-06-01 22:48:15 +09:00
}
2026-06-02 16:26:44 +09:00
function buildAssetMeta ( category , subtype , sourceDepthPixels , sourceLightPixels , lightColor , door , size = editorSize , sourceParticle = null ) {
2026-06-01 22:48:15 +09:00
const normalizedDepth = normalizeDepthPixels ( sourceDepthPixels || [ ] , size ) ;
const hasDepth = normalizedDepth . some ( Boolean ) ;
const lightPixelsClean = Array . isArray ( sourceLightPixels )
? sourceLightPixels
. map ( ( p ) => ( { x : clampInt ( p . x , 0 , size - 1 , 0 ) , y : clampInt ( p . y , 0 , size - 1 , 0 ) , c : p . c || nearestPaletteCode ( lightColor || '#ffd86a' ) } ) )
. filter ( ( p ) => Number . isFinite ( p . x ) && Number . isFinite ( p . y ) )
: [ ] ;
2026-06-02 16:26:44 +09:00
const legacyParticlePixels = Array . isArray ( sourceParticle )
? sourceParticle
. map ( ( p ) => ( { x : clampInt ( p . x , 0 , size - 1 , 0 ) , y : clampInt ( p . y , 0 , size - 1 , 0 ) , c : p . c || nearestPaletteCode ( '#ffffff' ) , dir : p . dir || 'up' } ) )
2026-06-02 02:35:22 +09:00
. filter ( ( p ) => Number . isFinite ( p . x ) && Number . isFinite ( p . y ) )
: [ ] ;
2026-06-02 16:26:44 +09:00
const particleSource = ! Array . isArray ( sourceParticle ) && sourceParticle
? sourceParticle
: ( legacyParticlePixels . length ? { enabled : true , c : legacyParticlePixels [ 0 ] . c , dir : legacyParticlePixels [ 0 ] . dir || 'up' } : null ) ;
const particleClean = normalizeParticleConfig ( particleSource || { enabled : false , c : nearestPaletteCode ( '#ffffff' ) , dir : 'up' } , size ) ;
const particleCells = legacyParticlePixels . map ( ( p ) => ( { x : p . x , y : p . y , c : PALETTE _BY _CODE [ p . c ] ? p . c : nearestPaletteCode ( '#ffffff' ) , dir : [ 'up' , 'down' , 'left' , 'right' ] . includes ( p . dir ) ? p . dir : ( particleClean . dir || 'up' ) } ) ) ;
2026-06-01 22:48:15 +09:00
const hasLight = lightPixelsClean . length > 0 ;
2026-06-02 16:26:44 +09:00
const hasParticles = particleCells . length > 0 ;
2026-06-01 22:48:15 +09:00
return {
hasLight ,
lightPixels : hasLight ? lightPixelsClean : [ ] ,
lightColor : hasLight ? nearestPaletteCode ( lightColor || '#ffd86a' ) : null ,
2026-06-02 02:35:22 +09:00
hasParticles ,
2026-06-02 16:26:44 +09:00
particlePixels : hasParticles ? particleCells : [ ] ,
particleConfig : { enabled : hasParticles , c : particleCells [ 0 ] ? . c || particleClean . c , dir : particleCells [ 0 ] ? . dir || particleClean . dir } ,
2026-06-01 22:48:15 +09:00
depthPixels : hasDepth ? encodeDepthPixels ( normalizedDepth ) : null ,
door : category === 'static' && subtype === 'building' && door ? { x : clampInt ( door . x , 0 , size - 1 , Math . floor ( size / 2 ) ) , y : clampInt ( door . y , 0 , size - 1 , size - 1 ) } : null
2026-06-01 18:27:25 +09:00
} ;
}
2026-06-01 22:48:15 +09:00
function normalizePlacements ( items ) {
return dedupeByAsset ( items ) . map ( ( item ) => ( {
id : item . id || uid ( ) ,
assetId : item . assetId ,
x : clampInt ( item . x , 0 , WORLD _W - 1 , 0 ) ,
y : clampInt ( item . y , 0 , WORLD _H - 1 , 0 ) ,
placedAt : item . placedAt || item . createdAt || Date . now ( ) ,
2026-06-02 02:35:22 +09:00
publishedAt : item . publishedAt || item . placedAt || item . createdAt || Date . now ( ) ,
status : item . status || 'active' ,
2026-06-01 22:48:15 +09:00
version : Number ( item . version ) || 1
} ) ) . filter ( ( item ) => item . assetId ) ;
}
function normalizeDynamicSummons ( items ) {
return dedupeByAsset ( items ) . map ( ( item ) => ( {
id : item . id || uid ( ) ,
assetId : item . assetId ,
homeX : clampInt ( item . homeX ? ? item . x , 0 , WORLD _W - 1 , 0 ) ,
homeY : clampInt ( item . homeY ? ? item . y , 0 , WORLD _H - 1 , 0 ) ,
createdAt : item . createdAt || item . placedAt || Date . now ( ) ,
2026-06-02 02:35:22 +09:00
publishedAt : item . publishedAt || item . createdAt || item . placedAt || Date . now ( ) ,
status : item . status || 'active' ,
2026-06-01 22:48:15 +09:00
version : Number ( item . version ) || 1
} ) ) . filter ( ( item ) => item . assetId ) ;
}
2026-06-01 18:27:25 +09:00
function dedupeByAsset ( items ) {
const map = new Map ( ) ;
for ( const item of items ) {
if ( item && item . assetId ) map . set ( item . assetId , item ) ;
}
return [ ... map . values ( ) ] ;
}
function findNearestTerrain ( type , startX , startY ) {
let best = null ;
let bestD = Infinity ;
const maxR = Math . max ( WORLD _W , WORLD _H ) ;
for ( let r = 0 ; r < maxR ; r ++ ) {
for ( let y = Math . max ( 0 , startY - r ) ; y <= Math . min ( WORLD _H - 1 , startY + r ) ; y ++ ) {
for ( let x = Math . max ( 0 , startX - r ) ; x <= Math . min ( WORLD _W - 1 , startX + r ) ; x ++ ) {
const tile = world . get ( x , y ) ;
if ( ! tile || tile . type !== type ) continue ;
const d = Math . hypot ( x - startX , y - startY ) ;
if ( d < bestD ) { bestD = d ; best = { x , y } ; }
}
}
if ( best ) return best ;
}
return { x : startX , y : startY } ;
}
function homeFromPos ( pos ) {
return { homeX : pos . x , homeY : pos . y } ;
}
function seedState ( ) {
const assets = [
2026-06-02 00:05:03 +09:00
makeAsset ( 'Hill Cottage' , 'static' , 'building' , drawCottage ( ) , { hasLight : true , lightPixels : [ { x : 8 , y : 8 } , { x : 9 , y : 8 } ] , lightColor : '#ffd86a' , door : { x : 7 , y : 14 } } ) ,
2026-06-02 02:35:22 +09:00
makeAsset ( 'Pine Cluster' , 'static' , 'nature' , drawPineCluster ( ) , { particlePixels : [ { x : 7 , y : 4 , c : nearestPaletteCode ( '#9bdc6d' ) } , { x : 10 , y : 6 , c : nearestPaletteCode ( '#f4a1bb' ) } ] } ) ,
makeAsset ( 'Shell Rock' , 'static' , 'nature' , drawShellRock ( ) , { particlePixels : [ { x : 8 , y : 9 , c : nearestPaletteCode ( '#ffffff' ) } ] } ) ,
2026-06-02 00:05:03 +09:00
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 ( ) )
2026-06-01 18:27:25 +09:00
] ;
const idByName = Object . fromEntries ( assets . map ( ( a ) => [ a . name , a . id ] ) ) ;
2026-06-02 00:05:03 +09:00
const shipPos = findNearestTerrain ( 'water' , 62 , 58 ) ;
const fishPos = findNearestTerrain ( 'water' , 66 , 60 ) ;
2026-06-01 18:27:25 +09:00
return {
2026-06-01 21:17:09 +09:00
schema : SAVE _SCHEMA ,
2026-06-01 18:27:25 +09:00
authorName : 'Local Artist' ,
assets ,
placed : [
2026-06-02 00:05:03 +09:00
{ 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 }
2026-06-01 18:27:25 +09:00
] ,
objectVotes : { } ,
assetVotes : { } ,
hiddenAssets : { } ,
hiddenObjects : { } ,
2026-06-02 00:05:03 +09:00
moderationReports : [ ] ,
guardrails : { ... PHASE5 _GUARDRAILS } ,
settings : defaultVisualSettings ( ) ,
2026-06-02 02:35:22 +09:00
account : null ,
publishLog : [ ] ,
2026-06-01 18:27:25 +09:00
dynamicSummons : [
2026-06-02 00:05:03 +09:00
{ 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 }
2026-06-01 18:27:25 +09:00
]
} ;
}
function makeAsset ( name , category , subtype , pixels , meta = { } , leftPixels = null ) {
const size = 16 ;
const id = uid ( ) ;
const right = alignPixelsToBottom ( normalizePixels ( pixels , size ) , size ) ;
2026-06-01 22:48:15 +09:00
const asset = {
2026-06-01 18:27:25 +09:00
id ,
name ,
category ,
subtype ,
size ,
pixels : encodePixels ( right ) ,
2026-06-01 22:48:15 +09:00
faces : category === 'dynamic' ? { right : encodePixels ( right ) , left : 'mirror' } : null ,
2026-06-01 18:27:25 +09:00
parentAssetId : null ,
originalAssetId : null ,
createdAt : Date . now ( ) ,
2026-06-01 22:48:15 +09:00
updatedAt : Date . now ( ) ,
2026-06-01 18:27:25 +09:00
author : 'Island Team' ,
2026-06-02 16:26:44 +09:00
meta : buildAssetMeta ( category , subtype , meta . depthPixels || [ ] , meta . lightPixels || [ ] , meta . lightColor || '#ffd86a' , meta . door || null , size , meta . particleConfig || meta . particlePixels || [ ] )
2026-06-01 18:27:25 +09:00
} ;
2026-06-01 22:48:15 +09:00
asset . contentHash = computeAssetContentHash ( asset ) ;
return asset ;
2026-06-01 18:27:25 +09:00
}
function alignPixelsToBottom ( pixels , size ) {
const source = normalizePixels ( pixels , size ) ;
let maxY = - 1 ;
for ( let y = 0 ; y < size ; y ++ ) {
for ( let x = 0 ; x < size ; x ++ ) {
if ( source [ y * size + x ] ) maxY = Math . max ( maxY , y ) ;
}
}
if ( maxY < 0 || maxY === size - 1 ) return source ;
const dy = size - 1 - maxY ;
const out = blankPixels ( size ) ;
for ( let y = 0 ; y < size ; y ++ ) {
for ( let x = 0 ; x < size ; x ++ ) {
const value = source [ y * size + x ] ;
if ( value && y + dy < size ) out [ ( y + dy ) * size + x ] = value ;
}
}
return out ;
}
function exportData ( ) {
els . dataBox . value = JSON . stringify ( state , null , 2 ) ;
2026-06-01 22:48:15 +09:00
toast ( 'Full JSON exported.' ) ;
}
function exportCompactData ( ) {
const payload = Phase2Sync ? . compactState ? Phase2Sync . compactState ( state ) : state ;
els . dataBox . value = JSON . stringify ( payload ) ;
toast ( 'Compact JSON exported.' ) ;
}
function exportSnapshotData ( ) {
if ( ! Phase2Sync ? . makeSnapshot ) {
exportData ( ) ;
return ;
}
els . dataBox . value = JSON . stringify ( Phase2Sync . makeSnapshot ( state ) ) ;
toast ( 'Snapshot JSON exported.' ) ;
}
function exportAssetBundleData ( ) {
if ( ! Phase2Sync ? . makeAssetBundle ) {
exportData ( ) ;
return ;
}
els . dataBox . value = JSON . stringify ( Phase2Sync . makeAssetBundle ( state , state . assets . map ( ( asset ) => asset . id ) ) ) ;
toast ( 'Asset bundle JSON exported.' ) ;
2026-06-01 18:27:25 +09:00
}
function importData ( ) {
try {
const imported = JSON . parse ( els . dataBox . value ) ;
2026-06-01 22:48:15 +09:00
if ( imported ? . format === Phase2Sync ? . SNAPSHOT _FORMAT ) {
toast ( 'Snapshot import needs matching assets; import a full/compact save or asset bundle instead.' ) ;
return ;
}
if ( Phase2Sync ? . isAssetBundle ? . ( imported ) ) {
const importedAssets = Phase2Sync . unpackAssetBundle ( imported ) . map ( normalizeAsset ) ;
const byId = new Map ( state . assets . map ( ( asset ) => [ asset . id , asset ] ) ) ;
importedAssets . forEach ( ( asset ) => byId . set ( asset . id , asset ) ) ;
state . assets = [ ... byId . values ( ) ] ;
saveState ( ) ;
spriteCache . clear ( ) ;
renderLibrary ( ) ;
updateSelectedLabel ( ) ;
toast ( ` ${ importedAssets . length } asset ${ importedAssets . length === 1 ? '' : 's' } imported. ` ) ;
return ;
}
const expanded = Phase2Sync ? . isCompactState ? . ( imported ) ? Phase2Sync . expandState ( imported ) : imported ;
state = normalizeState ( expanded ) ;
rebuildWorldIndex ( ) ;
2026-06-01 18:27:25 +09:00
hydrateAuthorUI ( ) ;
selectedAssetId = state . assets [ 0 ] ? . id ? ? null ;
saveState ( ) ;
2026-06-01 21:17:09 +09:00
spriteCache . clear ( ) ;
2026-06-01 18:27:25 +09:00
hydrateRuntime ( ) ;
renderLibrary ( ) ;
updateSelectedLabel ( ) ;
toast ( 'JSON imported.' ) ;
} catch ( error ) {
toast ( 'Invalid JSON.' ) ;
console . error ( error ) ;
}
}
function resetAll ( ) {
if ( ! confirm ( 'Reset all local assets and island objects?' ) ) return ;
localStorage . removeItem ( STORAGE _KEY ) ;
state = seedState ( ) ;
2026-06-01 22:48:15 +09:00
rebuildWorldIndex ( ) ;
2026-06-01 18:27:25 +09:00
hydrateAuthorUI ( ) ;
selectedAssetId = state . assets [ 0 ] ? . id ? ? null ;
saveState ( ) ;
2026-06-01 21:17:09 +09:00
spriteCache . clear ( ) ;
2026-06-01 18:27:25 +09:00
hydrateRuntime ( ) ;
renderLibrary ( ) ;
updateSelectedLabel ( ) ;
toast ( 'Local world reset.' ) ;
}
2026-06-01 22:48:15 +09:00
function recordSyncEvent ( event ) {
if ( ! event ) return ;
state . eventLog || = [ ] ;
state . eventLog . push ( event ) ;
state . eventLog = state . eventLog . slice ( - ( Phase2Sync ? . EVENT _LOG _LIMIT || 300 ) ) ;
state . sync || = { lastEventId : null } ;
state . sync . lastEventId = event . id ;
}
function cachePhase2State ( ) {
if ( ! Phase2Sync ? . cacheAssets ) return ;
Phase2Sync . cacheAssets ( state . assets || [ ] ) . catch ( ( error ) => console . warn ( 'Phase 2 asset cache failed.' , error ) ) ;
if ( Phase2Sync ? . makeSnapshot && Phase2Sync ? . cacheSnapshot ) {
Phase2Sync . cacheSnapshot ( Phase2Sync . makeSnapshot ( state ) ) . catch ( ( error ) => console . warn ( 'Phase 2 snapshot cache failed.' , error ) ) ;
}
}
2026-06-02 00:05:03 +09:00
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.' ) ;
}
2026-06-01 22:48:15 +09:00
function updateSyncStats ( ) {
if ( ! els . syncStats ) return ;
const report = Phase2Sync ? . compactSizeReport ? . ( state ) ;
if ( ! report ) {
els . syncStats . textContent = 'Phase 2 codec unavailable.' ;
return ;
}
const saved = report . savedPercent > 0 ? ` ${ report . savedPercent } % smaller ` : 'no saving yet' ;
const statsRect = getViewportWorldRect ( 64 ) ;
const visibleChunkCount = terrainCache ? . chunks ? . filter ( ( chunk ) => ! ( chunk . x + chunk . w < statsRect . left || chunk . x > statsRect . right || chunk . y + chunk . h < statsRect . top || chunk . y > statsRect . bottom ) ) . length || 0 ;
els . syncStats . textContent = ` Compact local save: ${ report . compactBytes . toLocaleString ( ) } bytes / full ${ report . fullBytes . toLocaleString ( ) } bytes ( ${ saved } ). Assets ${ report . assets } , objects ${ report . objects } , queued events ${ state . eventLog ? . length || 0 } . Terrain chunks ${ visibleChunkCount } / ${ terrainCache ? . chunks ? . length || 0 } . ` ;
}
function computeAssetContentHash ( asset ) {
const payload = [
asset . category || '' ,
asset . subtype || '' ,
asset . size || '' ,
asset . pixels || '' ,
asset . faces ? . right || '' ,
asset . faces ? . left || '' ,
asset . meta ? . depthPixels || '' ,
JSON . stringify ( asset . meta ? . lightPixels || [ ] ) ,
2026-06-02 02:35:22 +09:00
JSON . stringify ( asset . meta ? . particlePixels || [ ] ) ,
2026-06-02 16:26:44 +09:00
JSON . stringify ( asset . meta ? . particleConfig || null ) ,
2026-06-01 22:48:15 +09:00
asset . meta ? . door ? ` ${ asset . meta . door . x } , ${ asset . meta . door . y } ` : ''
] . join ( '|' ) ;
return ` fnv1a: ${ fnv1a ( payload ) } ` ;
}
function fnv1a ( value ) {
let hash = 0x811c9dc5 ;
for ( let i = 0 ; i < value . length ; i ++ ) {
hash ^= value . charCodeAt ( i ) ;
hash = Math . imul ( hash , 0x01000193 ) >>> 0 ;
}
return hash . toString ( 16 ) . padStart ( 8 , '0' ) ;
}
function clampInt ( value , min , max , fallback = min ) {
const n = Math . round ( Number ( value ) ) ;
return Number . isFinite ( n ) ? clamp ( n , min , max ) : fallback ;
}
2026-06-01 18:27:25 +09:00
function findAsset ( id ) {
return state . assets . find ( ( asset ) => asset . id === id ) || null ;
}
function blankPixels ( size ) {
return Array ( size * size ) . fill ( null ) ;
}
function normalizePixels ( pixels , size ) {
const out = blankPixels ( size ) ;
if ( typeof pixels === 'string' ) {
for ( let i = 0 ; i < Math . min ( out . length , pixels . length ) ; i ++ ) {
const ch = pixels [ i ] ;
out [ i ] = ch === '.' ? null : ( PALETTE _BY _CODE [ ch ] ? ch : nearestPaletteCode ( ch ) ) ;
}
return out ;
}
if ( ! Array . isArray ( pixels ) ) return out ;
for ( let i = 0 ; i < Math . min ( out . length , pixels . length ) ; i ++ ) {
const value = pixels [ i ] ;
if ( ! value ) out [ i ] = null ;
else if ( PALETTE _BY _CODE [ value ] ) out [ i ] = value ;
else out [ i ] = nearestPaletteCode ( value ) ;
}
return out ;
}
function encodePixels ( pixels ) {
return normalizePixels ( pixels , Math . sqrt ( pixels . length ) || editorSize ) . map ( ( value ) => value || '.' ) . join ( '' ) ;
}
function colorToHex ( value ) {
if ( ! value ) return 'rgba(0,0,0,0)' ;
return PALETTE _BY _CODE [ value ] || value ;
}
function nearestPaletteCode ( color ) {
if ( ! color || typeof color !== 'string' ) return null ;
if ( PALETTE _BY _CODE [ color ] ) return color ;
const rgb = parseHex ( color ) ;
if ( ! rgb ) return selectedColorCode ;
let best = PALETTE [ 0 ] . code ;
let bestDist = Infinity ;
for ( const entry of PALETTE ) {
const p = parseHex ( entry . color ) ;
const dist = ( rgb . r - p . r ) * * 2 + ( rgb . g - p . g ) * * 2 + ( rgb . b - p . b ) * * 2 ;
if ( dist < bestDist ) { bestDist = dist ; best = entry . code ; }
}
return best ;
}
function readableTextColor ( hex ) {
const rgb = parseHex ( hex ) ;
if ( ! rgb ) return '#243044' ;
const yiq = ( rgb . r * 299 + rgb . g * 587 + rgb . b * 114 ) / 1000 ;
return yiq > 140 ? '#243044' : '#fffdf5' ;
}
function parseHex ( hex ) {
if ( typeof hex !== 'string' || ! hex . startsWith ( '#' ) ) return null ;
const clean = hex . replace ( '#' , '' ) ;
const full = clean . length === 3 ? clean . split ( '' ) . map ( ( c ) => c + c ) . join ( '' ) : clean ;
const value = parseInt ( full , 16 ) ;
if ( ! Number . isFinite ( value ) ) return null ;
return { r : ( value >> 16 ) & 255 , g : ( value >> 8 ) & 255 , b : value & 255 } ;
}
2026-06-01 21:17:09 +09:00
function normalizeDepthPixels ( input , size ) {
const out = Array ( size * size ) . fill ( 0 ) ;
if ( typeof input === 'string' ) {
2026-06-01 22:48:15 +09:00
for ( let i = 0 ; i < Math . min ( out . length , input . length ) ; i ++ ) {
const ch = input [ i ] ;
out [ i ] = ch === '1' ? 1 : ( ch === '-' || ch === '_' || ch === 'l' || ch === 'L' ) ? - 1 : 0 ;
}
2026-06-01 21:17:09 +09:00
return out ;
}
if ( ! Array . isArray ( input ) ) return out ;
2026-06-01 22:48:15 +09:00
for ( let i = 0 ; i < Math . min ( out . length , input . length ) ; i ++ ) {
const value = Number ( input [ i ] ) || 0 ;
out [ i ] = value > 0 ? 1 : value < 0 ? - 1 : 0 ;
}
2026-06-01 21:17:09 +09:00
return out ;
}
function encodeDepthPixels ( input ) {
2026-06-01 22:48:15 +09:00
return Array . from ( input || [ ] ) . map ( ( v ) => Number ( v ) > 0 ? '1' : Number ( v ) < 0 ? '-' : '.' ) . join ( '' ) ;
2026-06-01 21:17:09 +09:00
}
function resizeDepthPixels ( source , oldSize , newSize ) {
const normalized = normalizeDepthPixels ( source , oldSize ) ;
const out = Array ( newSize * newSize ) . fill ( 0 ) ;
const min = Math . min ( oldSize , newSize ) ;
const xOffset = Math . floor ( ( newSize - min ) / 2 ) ;
const yOffset = Math . floor ( ( newSize - min ) / 2 ) ;
const oldOffset = Math . floor ( ( oldSize - min ) / 2 ) ;
for ( let y = 0 ; y < min ; y ++ ) {
for ( let x = 0 ; x < min ; x ++ ) {
out [ ( y + yOffset ) * newSize + ( x + xOffset ) ] = normalized [ ( y + oldOffset ) * oldSize + ( x + oldOffset ) ] || 0 ;
}
}
return out ;
}
2026-06-01 22:48:15 +09:00
function getAssetDepth ( asset , x , y , side = 'right' ) {
2026-06-01 21:17:09 +09:00
const depth = normalizeDepthPixels ( asset . meta ? . depthPixels || [ ] , asset . size || 1 ) ;
2026-06-01 22:48:15 +09:00
const sx = asset . category === 'dynamic' && side === 'left' ? asset . size - 1 - x : x ;
return depth [ y * asset . size + sx ] || 0 ;
2026-06-01 21:17:09 +09:00
}
function applyDepthToColor ( hex , depth ) {
2026-06-02 00:05:03 +09:00
return shadeAssetPixelColor ( hex , depth , 0 , 0 , 1 , renderPhase ) ;
}
2026-06-02 16:26:44 +09:00
function shadeAssetPixelColor ( hex , depth , x , y , size , phase , pixels = null , depths = null , lights = [ ] ) {
2026-06-01 21:17:09 +09:00
const rgb = parseHex ( hex ) ;
if ( ! rgb ) return hex ;
2026-06-02 00:05:03 +09:00
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 ;
2026-06-02 16:26:44 +09:00
const sunlight = ( activePhase ? . key === 'night' ? 6 : activePhase ? . key === 'evening' || activePhase ? . key === 'morning' ? 9 : 11 ) ;
const depthBoost = depth * sunlight * 0.35 ;
const exposure = ( vertical * 0.62 + horizontal * 0.5 ) * ( activePhase ? . key === 'night' ? 6 : 10 ) ;
const edgeBoost = getDepthEdgeLightBoost ( depth , x , y , size , pixels , depths , lights , activePhase ) ;
let r = rgb . r + depthBoost + exposure + edgeBoost ;
let g = rgb . g + depthBoost + exposure + edgeBoost ;
let b = rgb . b + depthBoost + exposure + edgeBoost ;
if ( areNightLightsActive ( activePhase ) && lights ? . length ) {
for ( const light of lights ) {
const lc = parseHex ( light . c || '#ffd86a' ) ;
if ( ! lc ) continue ;
const dist = Math . hypot ( ( light . x + 0.5 ) - ( x + 0.5 ) , ( light . y + 0.5 ) - ( y + 0.5 ) ) ;
const reach = Math . max ( 2.25 , size * 0.42 ) ;
const t = 1 - clamp ( dist / reach , 0 , 1 ) ;
if ( t <= 0 ) continue ;
const edge = getDepthLightFacing ( depth , x , y , size , pixels , depths , light . x , light . y ) ;
const depthFactor = depth > 0 ? 1.08 + edge * 0.30 : depth < 0 ? 0.72 + edge * 0.16 : 0.54 + edge * 0.08 ;
const strength = t * t * ( 10 + Math . abs ( depth ) * 7 ) * depthFactor ;
const mix = Math . min ( 0.52 , 0.12 + t * ( depth ? 0.24 : 0.18 ) ) ;
r = lerp ( r , lc . r + strength , mix ) ;
g = lerp ( g , lc . g + strength * 0.82 , mix * 0.94 ) ;
b = lerp ( b , lc . b + strength * 0.74 , mix * 0.88 ) ;
}
}
r = clamp ( r , 0 , 255 ) ;
g = clamp ( g , 0 , 255 ) ;
b = clamp ( b , 0 , 255 ) ;
2026-06-01 22:48:15 +09:00
return ` # ${ Math . round ( r ) . toString ( 16 ) . padStart ( 2 , '0' ) } ${ Math . round ( g ) . toString ( 16 ) . padStart ( 2 , '0' ) } ${ Math . round ( b ) . toString ( 16 ) . padStart ( 2 , '0' ) } ` ;
2026-06-01 21:17:09 +09:00
}
2026-06-02 16:26:44 +09:00
function mirrorDepthPixels ( depths , size ) {
const source = normalizeDepthPixels ( depths || [ ] , size ) ;
const out = Array ( size * size ) . fill ( 0 ) ;
for ( let y = 0 ; y < size ; y ++ ) {
for ( let x = 0 ; x < size ; x ++ ) out [ y * size + ( size - 1 - x ) ] = source [ y * size + x ] || 0 ;
}
return out ;
}
function getAssetLightPointsForSide ( asset , side = 'right' ) {
const size = asset . size || 1 ;
const lights = Array . isArray ( asset . meta ? . lightPixels ) ? asset . meta . lightPixels : [ ] ;
return lights . map ( ( p ) => ( {
x : asset . category === 'dynamic' && side === 'left' ? size - 1 - p . x : p . x ,
y : p . y ,
c : colorToHex ( p . c || asset . meta ? . lightColor || nearestPaletteCode ( '#ffd86a' ) )
} ) ) . filter ( ( p ) => Number . isFinite ( p . x ) && Number . isFinite ( p . y ) ) ;
}
function isObjectOutlinePixel ( pixels , x , y , size ) {
if ( ! pixels || ! pixels [ y * size + x ] ) return false ;
const dirs = [ [ 1 , 0 ] , [ - 1 , 0 ] , [ 0 , 1 ] , [ 0 , - 1 ] ] ;
for ( const [ dx , dy ] of dirs ) {
const nx = x + dx , ny = y + dy ;
if ( nx < 0 || ny < 0 || nx >= size || ny >= size ) return true ;
if ( ! pixels [ ny * size + nx ] ) return true ;
}
return false ;
}
function collectDepthEdgeDirs ( x , y , size , pixels , depths , depth ) {
if ( ! pixels || ! depths ) return [ ] ;
const dirs = [ [ 1 , 0 ] , [ - 1 , 0 ] , [ 0 , 1 ] , [ 0 , - 1 ] ] ;
const edgeDirs = [ ] ;
for ( const [ dx , dy ] of dirs ) {
const nx = x + dx , ny = y + dy ;
const neighborInside = nx >= 0 && ny >= 0 && nx < size && ny < size ;
const neighborOpaque = neighborInside && pixels [ ny * size + nx ] ;
const neighborDepth = neighborOpaque ? ( depths [ ny * size + nx ] || 0 ) : 0 ;
if ( ! neighborOpaque || neighborDepth !== depth ) edgeDirs . push ( [ dx , dy ] ) ;
}
return edgeDirs ;
}
function getDepthLightFacing ( depth , x , y , size , pixels , depths , lightX , lightY ) {
if ( ! depth || ! pixels || ! depths ) return 0 ;
const edgeDirs = collectDepthEdgeDirs ( x , y , size , pixels , depths , depth ) ;
if ( ! edgeDirs . length ) return 0 ;
const lx = lightX - x ;
const ly = lightY - y ;
const len = Math . hypot ( lx , ly ) || 1 ;
const vx = lx / len , vy = ly / len ;
let bestDot = - 1 ;
for ( const [ dx , dy ] of edgeDirs ) bestDot = Math . max ( bestDot , dx * vx + dy * vy ) ;
return depth > 0 ? Math . max ( 0 , bestDot ) : Math . max ( 0 , - bestDot ) ;
}
function getDepthEdgeLightBoost ( depth , x , y , size , pixels , depths , lights , phase ) {
if ( ! depth || ! pixels || ! depths || ! areNightLightsActive ( phase ) || ! lights ? . length ) return 0 ;
let best = null , bestD = Infinity ;
for ( const light of lights ) {
const d = Math . hypot ( light . x - x , light . y - y ) ;
if ( d < bestD ) { bestD = d ; best = light ; }
}
if ( ! best ) return 0 ;
const facing = getDepthLightFacing ( depth , x , y , size , pixels , depths , best . x , best . y ) ;
if ( ! facing ) return 0 ;
const outerEdge = collectDepthEdgeDirs ( x , y , size , pixels , depths , depth ) . some ( ( [ dx , dy ] ) => {
const nx = x + dx , ny = y + dy ;
return nx < 0 || ny < 0 || nx >= size || ny >= size || ! pixels [ ny * size + nx ] ;
} ) ;
const distanceFalloff = 1 - clamp ( bestD / Math . max ( 2.25 , size * 0.44 ) , 0 , 1 ) ;
if ( distanceFalloff <= 0 ) return 0 ;
const strength = outerEdge ? 0.64 : 1 ;
return facing * strength * distanceFalloff * distanceFalloff * ( depth > 0 ? 20 : 14 ) ;
}
2026-06-01 18:27:25 +09:00
function resizePixels ( source , oldSize , newSize ) {
const out = blankPixels ( newSize ) ;
const min = Math . min ( oldSize , newSize ) ;
const xOffset = Math . floor ( ( newSize - min ) / 2 ) ;
const yOffset = Math . floor ( ( newSize - min ) / 2 ) ;
const oldOffset = Math . floor ( ( oldSize - min ) / 2 ) ;
for ( let y = 0 ; y < min ; y ++ ) {
for ( let x = 0 ; x < min ; x ++ ) {
out [ ( y + yOffset ) * newSize + ( x + xOffset ) ] = source [ ( y + oldOffset ) * oldSize + ( x + oldOffset ) ] || null ;
}
}
return out ;
}
function mirrorPixels ( pixels , size ) {
const out = blankPixels ( size ) ;
for ( let y = 0 ; y < size ; y ++ ) {
for ( let x = 0 ; x < size ; x ++ ) {
out [ y * size + ( size - 1 - x ) ] = pixels [ y * size + x ] || null ;
}
}
return out ;
}
function hasAnyPixel ( pixels ) {
return Array . isArray ( pixels ) && pixels . some ( Boolean ) ;
}
function fade ( t ) {
return t * t * t * ( t * ( t * 6 - 15 ) + 10 ) ;
}
function hash2 ( ix , iy ) {
let h = ix * 374761393 + iy * 668265263 ;
h = ( h ^ ( h >> 13 ) ) * 1274126177 ;
return ( h ^ ( h >> 16 ) ) >>> 0 ;
}
function gradDot ( ix , iy , x , y ) {
const h = hash2 ( ix , iy ) & 7 ;
const gx = [ 1 , - 1 , 1 , - 1 , 1 , - 1 , 0 , 0 ] [ h ] ;
const gy = [ 1 , 1 , - 1 , - 1 , 0 , 0 , 1 , - 1 ] [ h ] ;
return gx * ( x - ix ) + gy * ( y - iy ) ;
}
function perlinNoise ( x , y ) {
const x0 = Math . floor ( x ) , y0 = Math . floor ( y ) ;
const x1 = x0 + 1 , y1 = y0 + 1 ;
const sx = fade ( x - x0 ) , sy = fade ( y - y0 ) ;
const n00 = gradDot ( x0 , y0 , x , y ) ;
const n10 = gradDot ( x1 , y0 , x , y ) ;
const n01 = gradDot ( x0 , y1 , x , y ) ;
const n11 = gradDot ( x1 , y1 , x , y ) ;
const ix0 = lerp ( n00 , n10 , sx ) ;
const ix1 = lerp ( n01 , n11 , sx ) ;
return lerp ( ix0 , ix1 , sy ) * . 5 + . 5 ;
}
function fractalPerlin ( x , y , octaves = 4 ) {
let amp = . 5 ;
let freq = 1 ;
let total = 0 ;
let norm = 0 ;
for ( let i = 0 ; i < octaves ; i ++ ) {
total += perlinNoise ( x * freq , y * freq ) * amp ;
norm += amp ;
amp *= . 5 ;
freq *= 2 ;
}
return total / norm ;
}
function smoothNoise ( x , y ) {
return Math . sin ( x * . 71 + y * . 37 ) * . 5 + Math . sin ( x * . 23 - y * . 61 ) * . 32 + Math . sin ( ( x + y ) * . 17 ) * . 18 ;
}
function cap ( value ) {
return ` ${ value } ` . charAt ( 0 ) . toUpperCase ( ) + ` ${ value } ` . slice ( 1 ) ;
}
function buildPalette ( ) {
const neutrals = [ '#fffdf7' , '#f3ead8' , '#dfd2bc' , '#c2b39d' , '#9b9083' , '#776d67' , '#5a5454' , '#403d42' , '#2d2d35' , '#14151c' ] ;
const hues = [ 0 , 24 , 48 , 72 , 96 , 132 , 168 , 204 , 228 , 264 , 288 , 324 , 348 ] ;
const lights = [ 72 , 58 , 46 , 34 ] ;
const colors = [ ... neutrals ] ;
for ( const light of lights ) {
for ( const hue of hues ) colors . push ( hslToHex ( hue , 72 , light ) ) ;
}
return COLOR _CODES . split ( '' ) . map ( ( code , index ) => ( { code , color : colors [ index ] } ) ) ;
}
function pseudoNoise ( value ) {
const x = Math . sin ( value * 12.9898 + 78.233 ) * 43758.5453 ;
return x - Math . floor ( x ) ;
}
function hslToHex ( h , s , l ) {
s /= 100 ; l /= 100 ;
const k = ( n ) => ( n + h / 30 ) % 12 ;
const a = s * Math . min ( l , 1 - l ) ;
const f = ( n ) => l - a * Math . max ( - 1 , Math . min ( k ( n ) - 3 , Math . min ( 9 - k ( n ) , 1 ) ) ) ;
const toHex = ( value ) => Math . round ( 255 * value ) . toString ( 16 ) . padStart ( 2 , '0' ) ;
return ` # ${ toHex ( f ( 0 ) ) } ${ toHex ( f ( 8 ) ) } ${ toHex ( f ( 4 ) ) } ` ;
}
function hexToRgba ( hex , alpha ) {
const clean = hex . replace ( '#' , '' ) ;
const bigint = parseInt ( clean . length === 3 ? clean . split ( '' ) . map ( ( c ) => c + c ) . join ( '' ) : clean , 16 ) ;
const r = ( bigint >> 16 ) & 255 ;
const g = ( bigint >> 8 ) & 255 ;
const b = bigint & 255 ;
return ` rgba( ${ r } , ${ g } , ${ b } , ${ alpha } ) ` ;
}
2026-06-02 00:05:03 +09:00
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 ) ) } ` ;
}
2026-06-01 18:27:25 +09:00
function toast ( message ) {
clearTimeout ( toastTimer ) ;
els . toast . textContent = message ;
els . toast . hidden = false ;
toastTimer = setTimeout ( ( ) => { els . toast . hidden = true ; } , 1900 ) ;
}
function px ( pixels , size , x , y , color ) {
if ( x >= 0 && x < size && y >= 0 && y < size ) pixels [ y * size + x ] = color ;
}
function rect ( pixels , size , x , y , w , h , color ) {
for ( let yy = y ; yy < y + h ; yy ++ ) for ( let xx = x ; xx < x + w ; xx ++ ) px ( pixels , size , xx , yy , color ) ;
}
function circle ( pixels , size , cx , cy , r , color ) {
for ( let y = Math . floor ( cy - r ) ; y <= Math . ceil ( cy + r ) ; y ++ ) {
for ( let x = Math . floor ( cx - r ) ; x <= Math . ceil ( cx + r ) ; x ++ ) {
if ( ( x - cx ) * * 2 + ( y - cy ) * * 2 <= r * * 2 ) px ( pixels , size , x , y , color ) ;
}
}
}
2026-06-02 00:05:03 +09:00
function drawCottage ( ) {
2026-06-01 18:27:25 +09:00
const s = 16 , p = blankPixels ( s ) ;
2026-06-02 00:05:03 +09:00
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' ) ;
2026-06-01 18:27:25 +09:00
for ( let y = 3 ; y <= 7 ; y ++ ) {
2026-06-02 00:05:03 +09:00
const inset = Math . abs ( 6 - y ) ;
for ( let x = 3 + inset ; x <= 12 - inset ; x ++ ) px ( p , s , x , y , y < 5 ? '#8e4f61' : '#74465b' ) ;
2026-06-01 18:27:25 +09:00
}
2026-06-02 00:05:03 +09:00
px ( p , s , 6 , 8 , '#ffcf75' ) ; px ( p , s , 11 , 12 , '#7b493f' ) ;
2026-06-01 18:27:25 +09:00
return p ;
}
2026-06-02 00:05:03 +09:00
function drawPineCluster ( ) {
2026-06-01 18:27:25 +09:00
const s = 16 , p = blankPixels ( s ) ;
2026-06-02 00:05:03 +09:00
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' ) ;
2026-06-01 18:27:25 +09:00
return p ;
}
2026-06-02 00:05:03 +09:00
function drawShellRock ( ) {
2026-06-01 18:27:25 +09:00
const s = 16 , p = blankPixels ( s ) ;
2026-06-02 00:05:03 +09:00
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' ) ;
2026-06-01 18:27:25 +09:00
return p ;
}
2026-06-02 00:05:03 +09:00
function drawShip ( ) {
2026-06-01 18:27:25 +09:00
const s = 16 , p = blankPixels ( s ) ;
2026-06-02 00:05:03 +09:00
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' ) ;
2026-06-01 18:27:25 +09:00
return p ;
}
2026-06-02 00:05:03 +09:00
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 drawFisherKidRight ( ) {
2026-06-01 18:27:25 +09:00
const s = 16 , p = blankPixels ( s ) ;
2026-06-02 00:05:03 +09:00
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' ) ;
2026-06-01 18:27:25 +09:00
return p ;
}
2026-06-02 00:05:03 +09:00
function drawCatRight ( ) {
2026-06-01 18:27:25 +09:00
const s = 16 , p = blankPixels ( s ) ;
2026-06-02 00:05:03 +09:00
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' ) ;
2026-06-01 18:27:25 +09:00
return p ;
}
2026-06-02 00:05:03 +09:00
function drawKoiRight ( ) {
2026-06-01 18:27:25 +09:00
const s = 16 , p = blankPixels ( s ) ;
2026-06-02 00:05:03 +09:00
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' ) ;
2026-06-01 18:27:25 +09:00
return p ;
}
2026-06-02 00:05:03 +09:00
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 ;
}
2026-06-01 18:27:25 +09:00
bootstrap ( ) ;
} ) ( ) ;