5863 lines
530 KiB
JavaScript
5863 lines
530 KiB
JavaScript
|
|
'use strict';
|
|||
|
|
const AppLogic=globalThis.BendAppLogic;
|
|||
|
|
if(!AppLogic)throw new Error('BendAppLogic is not loaded');
|
|||
|
|
const {key2,ckey,sameCell}=globalThis.BendPuzzle;
|
|||
|
|
const FieldPersistence=globalThis.BendFieldPersistence;
|
|||
|
|
if(!FieldPersistence)throw new Error('BendFieldPersistence is not loaded');
|
|||
|
|
const sectionCountRange=AppLogic.sectionCountRange,normalizeGeneratedShape=AppLogic.normalizeGeneratedShape,generatedShapeKey=AppLogic.generatedShapeKey,generatedShapeFamilyKey=AppLogic.generatedShapeFamilyKey,balancedShapeCandidates=AppLogic.balancedShapeCandidates,growConnectedShape=AppLogic.growConnectedShape,lineStrokeWidth=AppLogic.lineStrokeWidth;
|
|||
|
|
const APP_VERSION='47.77',SAVE_SCHEMA=31,STORAGE_SCHEMA=30,IDB_LAYOUT_VERSION=8,FIELD_STORAGE_FORMAT=2,GAMEPLAY_DATA_VERSION=3,WORLD_GENERATION='v47-field-reset-20260728-interaction-fix',CELL=43,CHUNK=5,UNIT=CELL*CHUNK,PAD=26;
|
|||
|
|
const GENERATOR_VERSION=5,MAX_SCORE=Number.MAX_SAFE_INTEGER,MAX_SOLVES=1000000000,MAX_WORLD_COORD=1000000000;
|
|||
|
|
const MAX_BOARDS=200000,MAX_PATHS_PER_BOARD=512,MAX_IMPORT_BYTES=100*1024*1024,CLOCK_DRIFT_LIMIT=5*60*1000,LOCAL_MIRROR_MAX_BYTES=4.5*1024*1024,IDB_STARTUP_TIMEOUT=8000,MAX_FRONTIER_GENERATION_CYCLES=3,GC_BATCH_ROWS=500;
|
|||
|
|
const AUXILIARY_FPS=30,AUXILIARY_FRAME_INTERVAL=1000/AUXILIARY_FPS,DRAG_TARGET_FPS=60,DRAG_FRAME_INTERVAL=1000/DRAG_TARGET_FPS,INTERACTION_FRAME_TOLERANCE_MS=1.25,DRAG_DISPLAY_WATCHDOG_MS=18,CAMERA_DISPLAY_WATCHDOG_MS=18,DRAG_MAX_POINTER_SAMPLES=12,DRAG_MAX_LOGICAL_SAMPLES_PER_FRAME=4,DRAG_MODEL_BUDGET_MS=.5,DRAG_MAX_CATCHUP_CELLS=24,LIGHTWEIGHT_DRAG_BOARD_CELLS=120,GENERATION_FAILURE_BONUS=2500,GENERATION_FAILURE_MIN_MS=8000,SHARED_EXPANSION_GRACE_MS=60000,SHARED_EXPANSION_JITTER_MS=30000,REALTIME_CURSOR_INTERVAL=50,REALTIME_CURSOR_HEARTBEAT_INTERVAL=5000,REALTIME_VIEWPORT_INTERVAL=250,REALTIME_PLAYER_STALE_MS=15000,REALTIME_CLAIM_REQUEST_TIMEOUT=5000,REALTIME_CLAIM_TOUCH_INTERVAL=20000,REALTIME_REACTION_DURATION=4500,REALTIME_REACTION_RATE_INTERVAL=500,REACTION_LONG_PRESS_MS=500,REACTION_MOVE_CANCEL_PX=18;
|
|||
|
|
const INTERACTION_SCHEDULER_VARIANT=(()=>{try{return localStorage.getItem('bend-field-interaction-scheduler-variant')==='reduced'?'reduced':'full'}catch(_){return'full'}})();
|
|||
|
|
globalThis.BEND_INTERACTION_SCHEDULER=Object.freeze({enabled:true,version:2,variant:INTERACTION_SCHEDULER_VARIANT});
|
|||
|
|
const LEGACY_LOCAL_SOLVER='\u3042\u306a\u305f',DEFAULT_PLAYER_NAME='\u65c5\u4eba';
|
|||
|
|
const AUTO_NAME_ADJECTIVES=Object.freeze(['\u9752\u3044','\u8d64\u3044','\u767d\u3044','\u9ed2\u3044','\u91d1\u8272\u306e','\u9280\u8272\u306e','\u9759\u304b\u306a','\u7d20\u65e9\u3044','\u3084\u3055\u3057\u3044','\u3075\u3057\u304e\u306a']);
|
|||
|
|
const AUTO_NAME_NOUNS=Object.freeze(['\u30ad\u30c4\u30cd','\u30cd\u30b3','\u30d5\u30af\u30ed\u30a6','\u30da\u30f3\u30ae\u30f3','\u30e9\u30c3\u30b3','\u30ab\u30e1','\u30af\u30b8\u30e9','\u30cf\u30ea\u30cd\u30ba\u30df','\u30ab\u30ef\u30a6\u30bd','\u30ed\u30dc\u30c3\u30c8']);
|
|||
|
|
function createAutomaticPlayerName(){const values=new Uint32Array(2);if(globalThis.crypto?.getRandomValues)globalThis.crypto.getRandomValues(values);else{values[0]=(Math.random()*0xffffffff)>>>0;values[1]=(Math.random()*0xffffffff)>>>0}return`${AUTO_NAME_ADJECTIVES[values[0]%AUTO_NAME_ADJECTIVES.length]}${AUTO_NAME_NOUNS[values[1]%AUTO_NAME_NOUNS.length]}-${String(((values[0]^values[1])>>>0)%1000).padStart(3,'0')}`}
|
|||
|
|
function currentPlayerName(){return typeof data?.playerName==='string'&&data.playerName.trim()?data.playerName.trim().slice(0,24):DEFAULT_PLAYER_NAME}
|
|||
|
|
function currentPlayerId(){return typeof data?.cloudProfile?.playerId==='string'?data.cloudProfile.playerId:null}
|
|||
|
|
const STARTER_SEED=0x51a7f00d;
|
|||
|
|
const STARTER_PUZZLE=Object.freeze({"g":[[0,0,"W"],[0,1,"N"],[1,4,"E"],[0,4,"E"],[4,0,"W"],[4,3,"S"],[2,4,"E"],[4,4,"E"],[4,1,"S"],[4,2,"S"]],"n":[[1,0,3],[1,2,2],[2,0,3],[4,4,2],[3,1,2]],"valid":[[0,0],[0,1],[0,2],[0,3],[0,4],[1,0],[1,1],[1,2],[1,3],[1,4],[2,0],[2,1],[2,2],[2,3],[2,4],[3,0],[3,1],[3,2],[3,3],[3,4],[4,0],[4,1],[4,2],[4,3],[4,4]],"bounds":{"w":5,"h":5},"axis":"MIX","solution":[{"startGate":0,"endGate":1,"cells":[[0,0],[1,0],[1,1],[0,1]]},{"startGate":2,"endGate":3,"cells":[[1,4],[1,3],[1,2],[0,2],[0,3],[0,4]]},{"startGate":4,"endGate":5,"cells":[[4,0],[3,0],[2,0],[2,1],[2,2],[2,3],[3,3],[4,3]]},{"startGate":6,"endGate":7,"cells":[[2,4],[3,4],[4,4]]},{"startGate":8,"endGate":9,"cells":[[4,1],[3,1],[3,2],[4,2]]}],"level":1,"maxTurns":3,"totalTurns":12,"style":"variable-world-gates","complexity":{"rating":1,"score":1,"areaUnits":1,"lineCount":5,"totalTurns":12,"avgTurns":2.4,"maxTurns":3,"avgLength":5,"maxLength":8},"difficulty":1,"regionalTarget":1});
|
|||
|
|
const SCORE_VERSION=6,STORE_PRICE_VERSION=1,MIN_STORE_ITEM_PRICE=3000,MIN_CURSOR_PRICE=500,MAX_FACE_CURSOR_PRICE=50000,FLAG_CURSOR_BASE_PRICE=10000,OECD_FLAG_CURSOR_BASE_PRICE=20000,STORE_CHANCE=1/30,DRAG_FLAG_CURSOR_SIZE=12.4,DRAG_FLAG_CLIP_RADIUS=5.8,MINIMAP_LONG_LINE=1024,MINIMAP_VIEW_CHUNKS_X=42;
|
|||
|
|
const MIN_CAMERA_SCALE=.08,SCORE_LENS_ZOOM_THRESHOLD=.72,OVERVIEW_ZOOM_THRESHOLD=.54,SOUND_GAIN_MULTIPLIER=3.6,UNIQUE_SOLUTION_MIN_LEVEL=6;
|
|||
|
|
const SPECIAL_CELL_MIN_LEVEL=5,SPECIAL_CELL_DEBUG_ALL_LEVELS=false;
|
|||
|
|
const DRAG_EDGE_MARGIN=76,DRAG_EDGE_MAX_SPEED=.58;
|
|||
|
|
const POINTER_SNAP_THRESHOLD=CELL*.45,POINTER_DOMINANT_RATIO=1.25;
|
|||
|
|
const TIME_ATTACK_MINUTES=Object.freeze([3,5,10]);
|
|||
|
|
const REACTION_EMOJIS=Object.freeze(['👍','👉🏻','🙏','🧠','🎉']);
|
|||
|
|
const TIME_ATTACK_COOLDOWN_MINUTES=Object.freeze({3:30,5:50,10:100});
|
|||
|
|
const TIME_ATTACK_TIERS=Object.freeze([
|
|||
|
|
Object.freeze({score:1000,multiplier:3}),
|
|||
|
|
Object.freeze({score:500,multiplier:2.5}),
|
|||
|
|
Object.freeze({score:250,multiplier:2}),
|
|||
|
|
Object.freeze({score:100,multiplier:1.5}),
|
|||
|
|
Object.freeze({score:0,multiplier:1.25})
|
|||
|
|
]);
|
|||
|
|
const STORE_ITEM_BASE=Object.freeze([
|
|||
|
|
Object.freeze({id:'score-lens',name:'ジェムレンズ',cost:200000,scoreLens:true,icon:'▦',effectLabel:'予想報酬表示 ON / OFF',toast:'予想報酬表示を切り替え',description:'未クリア盤面の予想報酬表示を切り替えます。'})
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
const YELLOW_FACE_CURSOR_SOURCE=`1F600|grinning face
|
|||
|
|
1F603|grinning face with big eyes
|
|||
|
|
1F604|grinning face with smiling eyes
|
|||
|
|
1F601|beaming face with smiling eyes
|
|||
|
|
1F606|grinning squinting face
|
|||
|
|
1F605|grinning face with sweat
|
|||
|
|
1F923|rolling on the floor laughing
|
|||
|
|
1F602|face with tears of joy
|
|||
|
|
1F642|slightly smiling face
|
|||
|
|
1F643|upside-down face
|
|||
|
|
1FAE0|melting face
|
|||
|
|
1F609|winking face
|
|||
|
|
1F60A|smiling face with smiling eyes
|
|||
|
|
1F607|smiling face with halo
|
|||
|
|
1F970|smiling face with hearts
|
|||
|
|
1F60D|smiling face with heart-eyes
|
|||
|
|
1F929|star-struck
|
|||
|
|
1F618|face blowing a kiss
|
|||
|
|
1F617|kissing face
|
|||
|
|
263A FE0F|smiling face
|
|||
|
|
1F61A|kissing face with closed eyes
|
|||
|
|
1F619|kissing face with smiling eyes
|
|||
|
|
1F972|smiling face with tear
|
|||
|
|
1F60B|face savoring food
|
|||
|
|
1F61B|face with tongue
|
|||
|
|
1F61C|winking face with tongue
|
|||
|
|
1F92A|zany face
|
|||
|
|
1F61D|squinting face with tongue
|
|||
|
|
1F911|money-mouth face
|
|||
|
|
1F917|smiling face with open hands
|
|||
|
|
1F92D|face with hand over mouth
|
|||
|
|
1FAE2|face with open eyes and hand over mouth
|
|||
|
|
1FAE3|face with peeking eye
|
|||
|
|
1F92B|shushing face
|
|||
|
|
1F914|thinking face
|
|||
|
|
1FAE1|saluting face
|
|||
|
|
1F910|zipper-mouth face
|
|||
|
|
1F928|face with raised eyebrow
|
|||
|
|
1F610|neutral face
|
|||
|
|
1F611|expressionless face
|
|||
|
|
1F636|face without mouth
|
|||
|
|
1FAE5|dotted line face
|
|||
|
|
1F636 200D 1F32B FE0F|face in clouds
|
|||
|
|
1F60F|smirking face
|
|||
|
|
1F612|unamused face
|
|||
|
|
1F644|face with rolling eyes
|
|||
|
|
1F62C|grimacing face
|
|||
|
|
1F62E 200D 1F4A8|face exhaling
|
|||
|
|
1F925|lying face
|
|||
|
|
1FAE8|shaking face
|
|||
|
|
1F642 200D 2194 FE0F|head shaking horizontally
|
|||
|
|
1F642 200D 2195 FE0F|head shaking vertically
|
|||
|
|
1F60C|relieved face
|
|||
|
|
1F614|pensive face
|
|||
|
|
1F62A|sleepy face
|
|||
|
|
1F924|drooling face
|
|||
|
|
1F634|sleeping face
|
|||
|
|
1FAE9|face with bags under eyes
|
|||
|
|
1F637|face with medical mask
|
|||
|
|
1F912|face with thermometer
|
|||
|
|
1F915|face with head-bandage
|
|||
|
|
1F927|sneezing face
|
|||
|
|
1F974|woozy face
|
|||
|
|
1F635|face with crossed-out eyes
|
|||
|
|
1F635 200D 1F4AB|face with spiral eyes
|
|||
|
|
1F92F|exploding head
|
|||
|
|
1F920|cowboy hat face
|
|||
|
|
1F973|partying face
|
|||
|
|
1F978|disguised face
|
|||
|
|
1F60E|smiling face with sunglasses
|
|||
|
|
1F913|nerd face
|
|||
|
|
1F9D0|face with monocle
|
|||
|
|
1F615|confused face
|
|||
|
|
1FAE4|face with diagonal mouth
|
|||
|
|
1F61F|worried face
|
|||
|
|
1F641|slightly frowning face
|
|||
|
|
2639 FE0F|frowning face
|
|||
|
|
1F62E|face with open mouth
|
|||
|
|
1F62F|hushed face
|
|||
|
|
1F632|astonished face
|
|||
|
|
1F633|flushed face
|
|||
|
|
1FAEA|distorted face
|
|||
|
|
1F97A|pleading face
|
|||
|
|
1F979|face holding back tears
|
|||
|
|
1F626|frowning face with open mouth
|
|||
|
|
1F627|anguished face
|
|||
|
|
1F628|fearful face
|
|||
|
|
1F630|anxious face with sweat
|
|||
|
|
1F625|sad but relieved face
|
|||
|
|
1F622|crying face
|
|||
|
|
1F62D|loudly crying face
|
|||
|
|
1F631|face screaming in fear
|
|||
|
|
1F616|confounded face
|
|||
|
|
1F623|persevering face
|
|||
|
|
1F61E|disappointed face
|
|||
|
|
1F613|downcast face with sweat
|
|||
|
|
1F629|weary face
|
|||
|
|
1F62B|tired face
|
|||
|
|
1F971|yawning face
|
|||
|
|
1F624|face with steam from nose
|
|||
|
|
1F620|angry face`;
|
|||
|
|
function titleEmojiName(name){return name.replace(/\b[a-z]/g,letter=>letter.toUpperCase())}
|
|||
|
|
const YELLOW_FACE_CURSOR_ITEMS=Object.freeze(YELLOW_FACE_CURSOR_SOURCE.split('\n').map((row,index)=>{
|
|||
|
|
const[codes,name]=row.split('|'),codeKey=codes.toLowerCase().replace(/\s+/g,'-'),
|
|||
|
|
emoji=String.fromCodePoint(...codes.split(' ').map(code=>Number.parseInt(code,16))),
|
|||
|
|
cost=MIN_CURSOR_PRICE+((index*61+37)%100)*MIN_CURSOR_PRICE;
|
|||
|
|
return Object.freeze({
|
|||
|
|
id:`cursor-face-${codeKey}`,name:titleEmojiName(name),cost,
|
|||
|
|
cursorStyle:`emoji-${codeKey}`,cursorEmoji:emoji,icon:emoji,
|
|||
|
|
effectLabel:'\u7d75\u6587\u5b57\u30ab\u30fc\u30bd\u30eb',toast:titleEmojiName(name),
|
|||
|
|
description:'\u9ec4\u8272\u3044\u8868\u60c5\u7d75\u6587\u5b57\u306e\u30ab\u30fc\u30bd\u30eb\u3067\u3059\u3002'
|
|||
|
|
});
|
|||
|
|
}));
|
|||
|
|
const FLAG_REGION_CODES=`AC AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CP CQ CR CU CV CW CX CY CZ DE DG DJ DK DM DO DZ EA EC EE EG EH ER ES ET EU FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU IC ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TA TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM UN US UY UZ VA VC VE VG VI VN VU WF WS XK YE YT ZA ZM ZW`.split(' ');
|
|||
|
|
const OECD_FLAG_CODES=new Set('AU AT BE CA CL CO CR CZ DK EE FI FR DE GR HU IS IE IL IT JP KR LV LT LU MX NL NZ NO PL PT SK SI ES SE CH TR GB US'.split(' '));
|
|||
|
|
function regionFlagEmoji(code){return String.fromCodePoint(...[...code].map(letter=>0x1f1e6+letter.charCodeAt(0)-65))}
|
|||
|
|
function subdivisionFlagEmoji(tag){return String.fromCodePoint(0x1f3f4,...[...tag].map(letter=>0xe0000+letter.charCodeAt(0)),0xe007f)}
|
|||
|
|
function emojiAssetKey(emoji){return[...emoji].map(character=>character.codePointAt(0).toString(16)).join('-')}
|
|||
|
|
const FLAG_CURSOR_ITEMS=Object.freeze([
|
|||
|
|
...FLAG_REGION_CODES.map(code=>({key:code.toLowerCase(),name:code,emoji:regionFlagEmoji(code),cost:OECD_FLAG_CODES.has(code)?OECD_FLAG_CURSOR_BASE_PRICE:FLAG_CURSOR_BASE_PRICE})),
|
|||
|
|
...[['gbeng','England'],['gbsct','Scotland'],['gbwls','Wales']].map(([key,name])=>({key,name,emoji:subdivisionFlagEmoji(key),cost:FLAG_CURSOR_BASE_PRICE}))
|
|||
|
|
].map(source=>Object.freeze({
|
|||
|
|
id:`cursor-flag-${source.key}`,name:source.name,cost:source.cost,
|
|||
|
|
cursorStyle:`flag-${source.key}`,cursorEmoji:source.emoji,icon:source.emoji,flagAsset:`assets/flags/${emojiAssetKey(source.emoji)}.svg`,
|
|||
|
|
effectLabel:'国旗カーソル',toast:source.emoji,description:''
|
|||
|
|
})));
|
|||
|
|
const CURSOR_ITEMS=Object.freeze([...YELLOW_FACE_CURSOR_ITEMS,...FLAG_CURSOR_ITEMS]);
|
|||
|
|
const STORE_ITEMS=Object.freeze([...STORE_ITEM_BASE,...CURSOR_ITEMS]);
|
|||
|
|
const STORE_ITEM_CATALOG=new Map(STORE_ITEMS.map(item=>[item.id,item]));
|
|||
|
|
const CURSOR_ITEM_BY_STYLE=new Map(CURSOR_ITEMS.map(item=>[item.cursorStyle,item]));
|
|||
|
|
const STORE_ITEM_IDS=new Set(STORE_ITEMS.map(item=>item.id));
|
|||
|
|
const LINE_COLORS=['#5fd8ff','#ff709f','#ffd45f','#72e38f','#a98cff','#ff915f','#5f8dff','#ff5f62','#42d6c4','#e66cff'];
|
|||
|
|
const DIFF_BACKGROUND_EASY=['#102b32','#17413f'],DIFF_BACKGROUND_HARD=['#43151f','#68202b'];
|
|||
|
|
const REGIONS=[{name:'\u68ee',accent:'#72e38f'},{name:'\u6f6e',accent:'#5fd8ff'},{name:'\u5bb5',accent:'#a98cff'},{name:'\u706b',accent:'#ff915f'},{name:'\u865a',accent:'#ff709f'}];
|
|||
|
|
const OPP={N:'S',S:'N',W:'E',E:'W'};
|
|||
|
|
const SIDE_D={N:[-1,0],S:[1,0],W:[0,-1],E:[0,1]};
|
|||
|
|
const storageKey=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:compact`,
|
|||
|
|
recoveryStorageKey=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:recovery`,
|
|||
|
|
recoveryJournalKey=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:journal`,
|
|||
|
|
recoveryJournalPrefix=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:journal:`,
|
|||
|
|
storageRevisionKey=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:revision`,
|
|||
|
|
worldEpochStorageKey=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:epoch`,
|
|||
|
|
syncStorageKey=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:signal`,
|
|||
|
|
worldDbName=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:world`,
|
|||
|
|
worldLockName=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:world`,
|
|||
|
|
CHAIN_WINDOW=120000,SAVE_DELAY=180;
|
|||
|
|
const mirrorChunkPrefix=`${storageKey}:chunk:`;
|
|||
|
|
const MIRROR_IDLE_DELAY=5000,MIRROR_IDLE_TIMEOUT=15000,MIRROR_CHUNK_BYTES=64*1024,MIRROR_CHUNK_FORMAT='bend-field-chunked-v1',MINIMAP_CACHE_OVERSCAN_CHUNKS=4,LOD_CHANGES_PER_PASS=1,BOARD_RENDERS_PER_FRAME=6,HYDRATE_CONCURRENCY=4;
|
|||
|
|
const RETIRED_WORLD_STORES=Object.freeze([
|
|||
|
|
Object.freeze({schema:31,generation:'v47-field-reset-20260728-bugfix'}),
|
|||
|
|
Object.freeze({schema:30,generation:'v47-field-reset-20260727'})
|
|||
|
|
]);
|
|||
|
|
const world=document.querySelector('#world'),viewport=document.querySelector('#viewport'),topbar=document.querySelector('#topbar'),overviewCanvas=document.querySelector('#overviewCanvas'),noiseCanvas=document.querySelector('#noiseCanvas'),
|
|||
|
|
solvedCountEl=document.querySelector('#solvedCount'),scoreCountEl=document.querySelector('#scoreCount'),selectedInfo=document.querySelector('#selectedInfo'),
|
|||
|
|
worldCountEl=document.querySelector('#worldCount'),fpsCounter=document.querySelector('#fpsCounter'),playerNameBtn=document.querySelector('#playerNameBtn'),settingsBtn=document.querySelector('#settingsBtn'),
|
|||
|
|
minimapCanvas=document.querySelector('#minimapCanvas'),minimapStatus=document.querySelector('#minimapStatus'),clearFeed=document.querySelector('#clearFeed'),presenceCanvas=document.querySelector('#presenceCanvas'),reactionCanvas=document.querySelector('#reactionCanvas'),reactionRadial=document.querySelector('#reactionRadial'),
|
|||
|
|
saveStatusEl=document.querySelector('#saveStatus'),statusPanel=document.querySelector('#statusPanel'),
|
|||
|
|
statusMessage=document.querySelector('#statusMessage'),specialTooltip=document.querySelector('#specialTooltip');
|
|||
|
|
const timeAttackSuggestion=document.querySelector('#timeAttackSuggestion');
|
|||
|
|
const cloudApiEnabled=document.querySelector('meta[name="bend-field-cloud-api"]')?.content==='on';
|
|||
|
|
const sessionId=globalThis.crypto?.randomUUID?.()||`session-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|||
|
|
const sessionRecoveryJournalKey=recoveryJournalPrefix+sessionId;
|
|||
|
|
const loadNotices=[];
|
|||
|
|
let volatileRecovery=null,storageAccessError=null,serverClockOffset=null;
|
|||
|
|
const sessionWallClock=Date.now(),sessionMonotonic=globalThis.performance?.now?.()||0;
|
|||
|
|
function deepClone(value){if(globalThis.structuredClone)try{return globalThis.structuredClone(value)}catch(_){}return JSON.parse(JSON.stringify(value))}
|
|||
|
|
function trustedNow(){
|
|||
|
|
const monotonic=sessionWallClock+((globalThis.performance?.now?.()||sessionMonotonic)-sessionMonotonic),wall=Date.now(),serverWall=Number.isFinite(serverClockOffset)?wall+serverClockOffset:null,floor=Number.isFinite(data?.clockFloor)?data.clockFloor:0;
|
|||
|
|
if(serverWall!=null)return Math.max(floor,serverWall);
|
|||
|
|
return Math.max(floor,Math.abs(wall-monotonic)>CLOCK_DRIFT_LIMIT?monotonic:wall);
|
|||
|
|
}
|
|||
|
|
function safeLocalGet(key){try{return localStorage.getItem(key)}catch(error){storageAccessError=error;return null}}
|
|||
|
|
function safeLocalSet(key,value){try{localStorage.setItem(key,value);storageAccessError=null;return true}catch(error){storageAccessError=error;return false}}
|
|||
|
|
function safeLocalRemove(key){try{localStorage.removeItem(key);return true}catch(error){storageAccessError=error;return false}}
|
|||
|
|
const UI_SETTINGS_KEY='bend-field:ui-settings:v1';
|
|||
|
|
function normalizeUiSettings(raw){return{lightweightRendering:raw?.lightweightRendering===true,soundEnabled:raw?.soundEnabled!==false}}
|
|||
|
|
function readUiSettings(){try{return normalizeUiSettings(JSON.parse(safeLocalGet(UI_SETTINGS_KEY)||'{}'))}catch(_){return normalizeUiSettings(null)}}
|
|||
|
|
let uiSettings=readUiSettings(),autoReducedEffects=INTERACTION_SCHEDULER_VARIANT==='reduced';
|
|||
|
|
function applyUiSettings(){document.body.dataset.interactionScheduler=INTERACTION_SCHEDULER_VARIANT;document.body.classList.toggle('lightweight-rendering',uiSettings.lightweightRendering);document.body.classList.toggle('reduced-effects',uiSettings.lightweightRendering||autoReducedEffects);if(uiSettings.lightweightRendering)pauseNoiseBackground?.();else scheduleNoiseBackground?.(true)}
|
|||
|
|
function persistUiSettings(){safeLocalSet(UI_SETTINGS_KEY,JSON.stringify(uiSettings));applyUiSettings()}
|
|||
|
|
function safeLocalKeys(prefix=''){
|
|||
|
|
try{const keys=[];for(let index=0;index<localStorage.length;index++){const key=localStorage.key(index);if(typeof key==='string'&&key.startsWith(prefix))keys.push(key)}return keys}catch(error){storageAccessError=error;return[]}
|
|||
|
|
}
|
|||
|
|
function validWorldEpoch(value){return typeof value==='string'&&/^[A-Za-z0-9._:-]{8,160}$/.test(value)}
|
|||
|
|
function createWorldEpoch(){const value=globalThis.crypto?.randomUUID?.()||`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${sessionId}`;return`world:${value}`}
|
|||
|
|
function storedWorldEpoch(){const value=safeLocalGet(worldEpochStorageKey);return validWorldEpoch(value)?value:null}
|
|||
|
|
function rememberWorldEpoch(value){return validWorldEpoch(value)&&safeLocalSet(worldEpochStorageKey,value)}
|
|||
|
|
function revisionVersion(value){return{rev:Number.isFinite(value?.rev)&&value.rev>=0?value.rev:0,revAuthor:typeof value?.revAuthor==='string'?value.revAuthor:typeof value?.author==='string'?value.author:''}}
|
|||
|
|
function compareRevisionVersions(left,right){const a=revisionVersion(left),b=revisionVersion(right);return a.rev!==b.rev?a.rev-b.rev:a.revAuthor.localeCompare(b.revAuthor)}
|
|||
|
|
function newerRevisionValue(left,right){return compareRevisionVersions(left,right)>=0?left:right}
|
|||
|
|
function clearRecoveryJournalKeys(){let ok=safeLocalRemove(recoveryJournalKey);for(const key of safeLocalKeys(recoveryJournalPrefix))ok=safeLocalRemove(key)&&ok;return ok}
|
|||
|
|
function deleteRetiredWorldData(){
|
|||
|
|
for(const retired of RETIRED_WORLD_STORES){
|
|||
|
|
const prefix=`bend-field:v${retired.schema}:${retired.generation}`;
|
|||
|
|
for(const suffix of[':compact',':recovery',':journal',':revision',':signal',':world:lease'])safeLocalRemove(prefix+suffix);
|
|||
|
|
for(const key of safeLocalKeys(`${prefix}:journal:`))safeLocalRemove(key);
|
|||
|
|
if(typeof indexedDB!=='undefined')try{indexedDB.deleteDatabase(`${prefix}:world`)}catch(_){}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
function manhattan(a,b){return Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1])}
|
|||
|
|
function isPlainObject(value){if(!value||typeof value!=='object'||Array.isArray(value))return false;const proto=Object.getPrototypeOf(value);return proto===Object.prototype||proto===null}
|
|||
|
|
function timeAttackMultiplier(score){return TIME_ATTACK_TIERS.find(tier=>score>=tier.score)?.multiplier||1.25}
|
|||
|
|
function normalizeTimeAttackRun(raw){
|
|||
|
|
if(!isPlainObject(raw)||!TIME_ATTACK_MINUTES.includes(raw.durationMinutes)||!Number.isFinite(raw.startedAt)||raw.startedAt<=0)return null;
|
|||
|
|
const durationMinutes=raw.durationMinutes,startedAt=raw.startedAt,endsAt=startedAt+durationMinutes*60000,
|
|||
|
|
collected=Number.isSafeInteger(raw.collected)&&raw.collected>0?Math.min(raw.collected,MAX_SCORE):0,
|
|||
|
|
baseCollected=Number.isSafeInteger(raw.baseCollected)&&raw.baseCollected>0?Math.min(raw.baseCollected,MAX_SCORE):0;
|
|||
|
|
return{
|
|||
|
|
id:typeof raw.id==='string'&&raw.id?raw.id.slice(0,64):`run-${Math.round(startedAt)}`,
|
|||
|
|
durationMinutes,startedAt,endsAt,
|
|||
|
|
collected,baseCollected,rewardPipelineVersion:2,scoreVersion:SCORE_VERSION,
|
|||
|
|
solves:Number.isSafeInteger(raw.solves)&&raw.solves>0?Math.min(raw.solves,MAX_SOLVES):0
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
function normalizeTimeAttackResult(raw){
|
|||
|
|
if(!isPlainObject(raw)||!TIME_ATTACK_MINUTES.includes(raw.durationMinutes))return null;
|
|||
|
|
const collected=Number.isSafeInteger(raw.collected)&&raw.collected>0?Math.min(raw.collected,MAX_SCORE):0,
|
|||
|
|
solves=Number.isSafeInteger(raw.solves)&&raw.solves>0?Math.min(raw.solves,MAX_SOLVES):0,
|
|||
|
|
baseCollected=Number.isSafeInteger(raw.baseCollected)&&raw.baseCollected>0?Math.min(raw.baseCollected,MAX_SCORE):0,
|
|||
|
|
multiplier=baseCollected?collected/baseCollected:1,total=collected,bonus=0,modifierGain=Math.max(0,collected-baseCollected);
|
|||
|
|
return{
|
|||
|
|
id:typeof raw.id==='string'&&raw.id?raw.id.slice(0,64):`result-${Math.round(raw.completedAt||Date.now())}`,
|
|||
|
|
durationMinutes:raw.durationMinutes,collected,baseCollected,rewardPipelineVersion:2,scoreVersion:SCORE_VERSION,solves,multiplier,total,bonus,modifierGain,
|
|||
|
|
completedAt:Number.isFinite(raw.completedAt)&&raw.completedAt>0?raw.completedAt:0
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
function normalizeTimeAttackCooldowns(raw){
|
|||
|
|
const cooldowns={3:0,5:0,10:0};
|
|||
|
|
if(isPlainObject(raw))for(const minutes of TIME_ATTACK_MINUTES){
|
|||
|
|
const value=raw[minutes];
|
|||
|
|
if(Number.isFinite(value)&&value>0)cooldowns[minutes]=value;
|
|||
|
|
}
|
|||
|
|
return cooldowns;
|
|||
|
|
}
|
|||
|
|
function normalizeBonusEvents(raw){
|
|||
|
|
const events={};
|
|||
|
|
if(isPlainObject(raw))for(const[id,value]of Object.entries(raw).slice(0,100000))if(typeof id==='string'&&id.length<=128&&Number.isSafeInteger(value)&&value>0)events[id]=Math.min(value,MAX_SCORE);
|
|||
|
|
return events;
|
|||
|
|
}
|
|||
|
|
function bonusEventTotal(events=data?.bonusEvents){let total=0;for(const value of Object.values(events||{}))total=Math.min(MAX_SCORE,total+value);return total}
|
|||
|
|
function normalizeCloudPending(raw){
|
|||
|
|
const ids=value=>[...new Set((Array.isArray(value)?value:[]).filter(id=>typeof id==='string'&&/^B(?:0|[1-9]\d*)$/.test(id)).slice(0,MAX_BOARDS))];
|
|||
|
|
return{metaIds:ids(raw?.metaIds),stateIds:ids(raw?.stateIds),deleted:ids(raw?.deleted),globalChanged:raw?.globalChanged===true};
|
|||
|
|
}
|
|||
|
|
function normalizePlayerPurchases(raw){
|
|||
|
|
const purchases=[],seen=new Set();
|
|||
|
|
for(const source of Array.isArray(raw)?raw:[]){
|
|||
|
|
const purchaseId=typeof source?.purchaseId==='string'?source.purchaseId.slice(0,64):'',boardId=typeof source?.boardId==='string'?source.boardId.slice(0,32):'',item=storeItem(source?.itemId);
|
|||
|
|
if(!purchaseId||!boardId||!item||seen.has(purchaseId))continue;
|
|||
|
|
const boughtAt=Number(source.boughtAt);
|
|||
|
|
seen.add(purchaseId);purchases.push({purchaseId,boardId,itemId:item.id,buyer:typeof source.buyer==='string'&&source.buyer.trim()?source.buyer.trim().slice(0,32):DEFAULT_PLAYER_NAME,boughtAt:Number.isFinite(boughtAt)&&boughtAt>0?boughtAt:0,paidCost:Number.isSafeInteger(source.paidCost)&&source.paidCost>0?Math.min(source.paidCost,MAX_SCORE):item.cost});
|
|||
|
|
}
|
|||
|
|
return purchases;
|
|||
|
|
}
|
|||
|
|
function defaultData(){return{schema:SAVE_SCHEMA,gameplayVersion:GAMEPLAY_DATA_VERSION,worldGeneration:WORLD_GENERATION,worldEpoch:null,globalRev:0,globalRevAuthor:'',metas:{},states:{},quarantine:{},bonusEvents:{},specialMechanicsSeen:[],clockFloor:0,cloudProfile:null,cloudRevision:0,cloudSyncPaused:false,cloudPending:normalizeCloudPending(null),playerName:null,playerPurchases:[],playerEarnedScore:0,lastReaction:'👍',worldFeedRevision:0,nextId:1,solved:0,score:0,bonusScore:0,bonusScoreVersion:SCORE_VERSION,lastSolveAt:0,timeAttack:null,timeAttackRev:0,timeAttackCooldowns:{3:0,5:0,10:0},lastTimeAttack:null,timeAttackSuggestionsDisabled:false,cursorStyle:'default',scoreLensEnabled:false,debugAllItems:false,cameraAnchor:null,selectedBoardId:null,updatedAt:0}}
|
|||
|
|
function validChunkShape(chunks){
|
|||
|
|
if(!Array.isArray(chunks)||chunks.length<1||chunks.length>50)return false;
|
|||
|
|
const set=new Set();
|
|||
|
|
for(const q of chunks){
|
|||
|
|
if(!Array.isArray(q)||q.length!==2||!Number.isInteger(q[0])||!Number.isInteger(q[1])||q[0]<0||q[1]<0||q[0]>64||q[1]>64)return false;
|
|||
|
|
const key=key2(q[0],q[1]);if(set.has(key))return false;set.add(key);
|
|||
|
|
}
|
|||
|
|
if(Math.min(...chunks.map(q=>q[0]))!==0||Math.min(...chunks.map(q=>q[1]))!==0)return false;
|
|||
|
|
const reached=new Set([key2(...chunks[0])]),queue=[chunks[0]];
|
|||
|
|
while(queue.length){const[x,y]=queue.shift();for(const[dx,dy]of[[1,0],[-1,0],[0,1],[0,-1]]){const key=key2(x+dx,y+dy);if(set.has(key)&&!reached.has(key)){reached.add(key);queue.push([x+dx,y+dy])}}}
|
|||
|
|
return reached.size===set.size;
|
|||
|
|
}
|
|||
|
|
function normalizePath(raw){
|
|||
|
|
if(!isPlainObject(raw)||!Number.isInteger(raw.startGate)||!Array.isArray(raw.cells)||raw.cells.length<1||raw.cells.length>5000)return null;
|
|||
|
|
if(raw.endGate!=null&&!Number.isInteger(raw.endGate))return null;
|
|||
|
|
const cells=[];
|
|||
|
|
for(const cell of raw.cells){
|
|||
|
|
if(!Array.isArray(cell)||cell.length!==2||!Number.isInteger(cell[0])||!Number.isInteger(cell[1]))return null;
|
|||
|
|
cells.push([cell[0],cell[1]]);
|
|||
|
|
}
|
|||
|
|
const color=value=>Number.isInteger(value)&&value>=0&&value<LINE_COLORS.length?value:null;
|
|||
|
|
const base=color(raw.colorIndex)??0;
|
|||
|
|
const endGate=raw.endGate??null,detachedStart=raw.detachedStart===true&&endGate==null;
|
|||
|
|
return{startGate:raw.startGate,endGate,openGate:Number.isInteger(raw.openGate)?raw.openGate:null,detachedStart,cells,colorIndex:base,startColorIndex:color(raw.startColorIndex)??base,endColorIndex:color(raw.endColorIndex)};
|
|||
|
|
}
|
|||
|
|
function normalizeSpecialProgress(raw){
|
|||
|
|
const crossings=[];
|
|||
|
|
for(const key of Array.isArray(raw?.crossings)?raw.crossings:[]){
|
|||
|
|
if(typeof key!=='string'||!/^-?\d+,-?\d+$/.test(key)||crossings.includes(key))continue;
|
|||
|
|
crossings.push(key);
|
|||
|
|
}
|
|||
|
|
return{crossings:crossings.slice(0,64)};
|
|||
|
|
}
|
|||
|
|
function mergeSpecialProgress(a,b){
|
|||
|
|
return normalizeSpecialProgress({crossings:[...(a?.crossings||[]),...(b?.crossings||[])]});
|
|||
|
|
}
|
|||
|
|
function normalizeSpecialCells(raw,validSet){
|
|||
|
|
const result={crossings:[],warps:[],locks:[]},reserved=new Set();
|
|||
|
|
const readCell=cell=>{
|
|||
|
|
if(!Array.isArray(cell)||cell.length!==2||!Number.isInteger(cell[0])||!Number.isInteger(cell[1]))return null;
|
|||
|
|
const key=ckey(cell[0],cell[1]);
|
|||
|
|
return validSet.has(key)?[cell[0],cell[1]]:null;
|
|||
|
|
};
|
|||
|
|
for(const rawCell of Array.isArray(raw?.crossings)?raw.crossings:[]){
|
|||
|
|
const cell=readCell(rawCell);if(!cell)continue;const key=ckey(...cell);if(reserved.has(key))continue;
|
|||
|
|
reserved.add(key);result.crossings.push(cell);
|
|||
|
|
}
|
|||
|
|
for(const rawPair of Array.isArray(raw?.warps)?raw.warps:[]){
|
|||
|
|
const a=readCell(rawPair?.a??rawPair?.[0]),b=readCell(rawPair?.b??rawPair?.[1]);
|
|||
|
|
if(!a||!b||sameCell(a,b)||manhattan(a,b)===1)continue;
|
|||
|
|
const ak=ckey(...a),bk=ckey(...b);if(reserved.has(ak)||reserved.has(bk))continue;
|
|||
|
|
reserved.add(ak);reserved.add(bk);result.warps.push({a,b});
|
|||
|
|
}
|
|||
|
|
for(const rawLock of Array.isArray(raw?.locks)?raw.locks:[]){
|
|||
|
|
const keyCell=readCell(rawLock?.key),door=readCell(rawLock?.door);
|
|||
|
|
if(!keyCell||!door||sameCell(keyCell,door))continue;
|
|||
|
|
const kk=ckey(...keyCell),dk=ckey(...door);if(reserved.has(kk)||reserved.has(dk))continue;
|
|||
|
|
reserved.add(kk);reserved.add(dk);result.locks.push({key:keyCell,door});
|
|||
|
|
}
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
function normalizeStore(raw,paths){
|
|||
|
|
if(!isPlainObject(raw)||!Number.isInteger(raw.pathIndex)||raw.pathIndex<0||raw.pathIndex>=paths.length)return null;
|
|||
|
|
const path=paths[raw.pathIndex];
|
|||
|
|
if(!path?.cells?.length)return null;
|
|||
|
|
const purchases=[],seen=new Set();
|
|||
|
|
for(const purchase of Array.isArray(raw.purchases)?raw.purchases:[]){
|
|||
|
|
const id=typeof purchase==='string'?purchase:purchase?.id;
|
|||
|
|
if(!STORE_ITEM_IDS.has(id)||seen.has(id))continue; const item=storeItem(id);
|
|||
|
|
seen.add(id);purchases.push({
|
|||
|
|
id,
|
|||
|
|
buyer:typeof purchase?.buyer==='string'&&purchase.buyer.trim()?purchase.buyer.trim().slice(0,32):DEFAULT_PLAYER_NAME,
|
|||
|
|
boughtAt:Number.isFinite(purchase?.boughtAt)&&purchase.boughtAt>0?purchase.boughtAt:0,
|
|||
|
|
paidCost:Number.isSafeInteger(purchase?.paidCost)&&purchase.paidCost>0?Math.min(purchase.paidCost,MAX_SCORE):item.cost
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
const rawBonus=Number.isSafeInteger(raw.bonus)&&raw.bonus>0?Math.min(raw.bonus,MAX_SCORE):0;
|
|||
|
|
const itemIds=normalizeStoreItemIds(raw.itemIds);
|
|||
|
|
return{
|
|||
|
|
owner:typeof raw.owner==='string'&&raw.owner.trim()?raw.owner.trim().slice(0,32):DEFAULT_PLAYER_NAME,
|
|||
|
|
pathIndex:raw.pathIndex,
|
|||
|
|
cellIndex:Math.max(0,Math.min(path.cells.length-1,Number.isInteger(raw.cellIndex)?raw.cellIndex:Math.floor(path.cells.length/2))),
|
|||
|
|
openedAt:Number.isFinite(raw.openedAt)&&raw.openedAt>0?raw.openedAt:0,
|
|||
|
|
priceVersion:Number.isInteger(raw.priceVersion)&&raw.priceVersion>0?raw.priceVersion:STORE_PRICE_VERSION,
|
|||
|
|
priceCoefficient:Number.isFinite(raw.priceCoefficient)&&raw.priceCoefficient>=.8&&raw.priceCoefficient<=1.2?raw.priceCoefficient:null,
|
|||
|
|
bonus:rawBonus,
|
|||
|
|
bonusVersion:SCORE_VERSION,
|
|||
|
|
itemIds,
|
|||
|
|
purchases
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
function normalizeState(raw){
|
|||
|
|
const state={paths:[],specialProgress:{crossings:[]},solved:false,expanded:false,expansionRetryRound:0,solvedBy:null,solvedById:null,solvedAt:null,scoreAwarded:0,scoreVersion:SCORE_VERSION,rewardIdentity:null,rewardCoefficient:null,store:null,rev:0,revAuthor:''};
|
|||
|
|
if(!isPlainObject(raw))return state;
|
|||
|
|
if(Array.isArray(raw.paths))for(const candidate of raw.paths.slice(0,MAX_PATHS_PER_BOARD)){const path=normalizePath(candidate);if(path)state.paths.push(path)}
|
|||
|
|
state.specialProgress=normalizeSpecialProgress(raw.specialProgress);
|
|||
|
|
state.solved=raw.solved===true;
|
|||
|
|
state.expanded=raw.expanded===true;
|
|||
|
|
state.expansionRetryRound=Number.isInteger(raw.expansionRetryRound)&&raw.expansionRetryRound>=0?Math.min(raw.expansionRetryRound,1000000):0;
|
|||
|
|
if(typeof raw.solvedBy==='string'&&raw.solvedBy.trim())state.solvedBy=raw.solvedBy.trim().slice(0,32);
|
|||
|
|
if(typeof raw.solvedById==='string'&&/^[a-f0-9]{16,64}$/i.test(raw.solvedById))state.solvedById=raw.solvedById.toLowerCase();
|
|||
|
|
if(state.solved&&!state.solvedBy)state.solvedBy=LEGACY_LOCAL_SOLVER;
|
|||
|
|
if(state.solved&&Number.isFinite(raw.solvedAt)&&raw.solvedAt>0)state.solvedAt=raw.solvedAt;
|
|||
|
|
if(state.solved&&Number.isSafeInteger(raw.scoreAwarded)&&raw.scoreAwarded>0){
|
|||
|
|
state.scoreAwarded=Math.min(raw.scoreAwarded,MAX_SCORE);
|
|||
|
|
}
|
|||
|
|
if(state.solved&&Number.isFinite(raw.rewardIdentity)&&raw.rewardIdentity>=0)state.rewardIdentity=raw.rewardIdentity>>>0;
|
|||
|
|
if(state.solved&&Number.isFinite(raw.rewardCoefficient)&&raw.rewardCoefficient>=.8&&raw.rewardCoefficient<=1.2)state.rewardCoefficient=raw.rewardCoefficient;
|
|||
|
|
if(state.solved)state.store=normalizeStore(raw.store,state.paths);
|
|||
|
|
state.rev=Number.isFinite(raw.rev)&&raw.rev>=0?raw.rev:0;state.revAuthor=typeof raw.revAuthor==='string'?raw.revAuthor.slice(0,128):'';
|
|||
|
|
return state;
|
|||
|
|
}
|
|||
|
|
function repairWarpNumberClues(puzzle,sourceNumbers){
|
|||
|
|
const warps=puzzle?.specialCells?.warps||[];if(!warps.length)return{numbers:sourceNumbers,repaired:false,maxTurns:puzzle.maxTurns||0,totalTurns:puzzle.totalTurns||0};
|
|||
|
|
const reserved=new Set();for(const cell of puzzle.specialCells.crossings||[])reserved.add(ckey(...cell));for(const pair of warps){reserved.add(ckey(...pair.a));reserved.add(ckey(...pair.b))}for(const lock of puzzle.specialCells.locks||[]){reserved.add(ckey(...lock.key));reserved.add(ckey(...lock.door))}
|
|||
|
|
const used=new Set(),numbers=[];let maxTurns=0,totalTurns=0;
|
|||
|
|
for(const path of puzzle.solution){
|
|||
|
|
const analysis=AppLogic.analyzePathTurns(path,puzzle.g,warps,true);if(!analysis.count||!analysis.cells.length)return null;
|
|||
|
|
const bendKeys=new Set(analysis.cells.map(cell=>ckey(...cell))),existing=(sourceNumbers||[]).find(number=>path.cells.some(cell=>sameCell(cell,number))&&bendKeys.has(ckey(number[0],number[1]))&&!used.has(ckey(number[0],number[1]))),
|
|||
|
|
pool=analysis.cells.filter(cell=>!reserved.has(ckey(...cell))&&!used.has(ckey(...cell))),fallback=analysis.cells.filter(cell=>!used.has(ckey(...cell))),cell=existing?[existing[0],existing[1]]:(pool[0]||fallback[0]);
|
|||
|
|
if(!cell)return null;used.add(ckey(...cell));numbers.push([cell[0],cell[1],analysis.count]);maxTurns=Math.max(maxTurns,analysis.count);totalTurns+=analysis.count;
|
|||
|
|
}
|
|||
|
|
const repaired=JSON.stringify(numbers)!==JSON.stringify(sourceNumbers||[])||maxTurns!==(puzzle.maxTurns||0)||totalTurns!==(puzzle.totalTurns||0);
|
|||
|
|
return{numbers,repaired,maxTurns,totalTurns};
|
|||
|
|
}
|
|||
|
|
function normalizeStoredPuzzle(raw,chunks,targetLevel){
|
|||
|
|
if(!isPlainObject(raw)||!Array.isArray(raw.g)||!Array.isArray(raw.n)||!Array.isArray(raw.valid)||!Array.isArray(raw.solution))return null;
|
|||
|
|
const maxRows=Math.max(...chunks.map(([,y])=>(y+1)*5)),maxCols=Math.max(...chunks.map(([x])=>(x+1)*5)),validSet=new Set(),valid=[];
|
|||
|
|
for(const cell of raw.valid){if(!Array.isArray(cell)||cell.length!==2||!Number.isInteger(cell[0])||!Number.isInteger(cell[1])||cell[0]<0||cell[1]<0||cell[0]>=maxRows||cell[1]>=maxCols)return null;const key=ckey(cell[0],cell[1]);if(validSet.has(key))return null;validSet.add(key);valid.push([cell[0],cell[1]])}
|
|||
|
|
const obstacleSet=new Set(),obstacles=[];
|
|||
|
|
for(const cell of Array.isArray(raw.obstacles)?raw.obstacles:[]){if(!Array.isArray(cell)||cell.length!==2||!Number.isInteger(cell[0])||!Number.isInteger(cell[1])||cell[0]<0||cell[1]<0||cell[0]>=maxRows||cell[1]>=maxCols)return null;const key=ckey(cell[0],cell[1]);if(validSet.has(key)||obstacleSet.has(key))return null;obstacleSet.add(key);obstacles.push([cell[0],cell[1]])}
|
|||
|
|
if(valid.length+obstacles.length!==chunks.length*25)return null;
|
|||
|
|
const gates=[];
|
|||
|
|
for(const gate of raw.g){if(!Array.isArray(gate)||gate.length!==3||!Number.isInteger(gate[0])||!Number.isInteger(gate[1])||!validSet.has(ckey(gate[0],gate[1]))||!['N','S','W','E'].includes(gate[2]))return null;gates.push([gate[0],gate[1],gate[2]])}
|
|||
|
|
if(!gates.length||gates.length%2!==0)return null;
|
|||
|
|
const numbers=[];
|
|||
|
|
for(const number of raw.n){if(!Array.isArray(number)||number.length!==3||!Number.isInteger(number[0])||!Number.isInteger(number[1])||!Number.isInteger(number[2])||number[2]<0||number[2]>512||!validSet.has(ckey(number[0],number[1])))return null;numbers.push([number[0],number[1],number[2]])}
|
|||
|
|
const specialCells=normalizeSpecialCells(raw.specialCells,validSet),warpPairs=new Map();
|
|||
|
|
for(const pair of specialCells.warps){warpPairs.set(ckey(...pair.a),ckey(...pair.b));warpPairs.set(ckey(...pair.b),ckey(...pair.a))}
|
|||
|
|
const solution=[];
|
|||
|
|
for(const path of raw.solution){
|
|||
|
|
const normalized=normalizePath(path);
|
|||
|
|
if(!normalized||normalized.endGate==null||normalized.startGate<0||normalized.endGate<0||normalized.startGate>=gates.length||normalized.endGate>=gates.length||normalized.cells.some(cell=>!validSet.has(ckey(...cell))))return null;
|
|||
|
|
for(let index=1;index<normalized.cells.length;index++){
|
|||
|
|
const previous=normalized.cells[index-1],cell=normalized.cells[index],warp=warpPairs.get(ckey(...previous));
|
|||
|
|
if(manhattan(previous,cell)!==1&&warp!==ckey(...cell))return null;
|
|||
|
|
}
|
|||
|
|
solution.push(normalized);
|
|||
|
|
}
|
|||
|
|
if(!solution.length)return null;
|
|||
|
|
const covered=new Map(),crossingSet=new Set(specialCells.crossings.map(cell=>ckey(...cell)));
|
|||
|
|
for(const path of solution)for(const cell of path.cells){const key=ckey(...cell),count=covered.get(key)||0;if(count&&(!crossingSet.has(key)||count>=2))return null;covered.set(key,count+1)}
|
|||
|
|
if(covered.size!==valid.length)return null;
|
|||
|
|
const bounds=isPlainObject(raw.bounds)&&Number.isInteger(raw.bounds.w)&&Number.isInteger(raw.bounds.h)?{w:raw.bounds.w,h:raw.bounds.h}:{w:maxCols,h:maxRows};
|
|||
|
|
const puzzle={g:gates,n:numbers,valid,obstacles,specialCells,bounds,axis:typeof raw.axis==='string'?raw.axis:'MIX',solution,level:Number.isInteger(raw.level)?raw.level:targetLevel,maxTurns:Number.isFinite(raw.maxTurns)?raw.maxTurns:0,totalTurns:Number.isFinite(raw.totalTurns)?raw.totalTurns:0,style:typeof raw.style==='string'?raw.style:'stored-procedural',regionalTarget:Number.isInteger(raw.regionalTarget)?raw.regionalTarget:targetLevel,
|
|||
|
|
uniqueness:isPlainObject(raw.uniqueness)?deepClone(raw.uniqueness):null,solutionQuality:isPlainObject(raw.solutionQuality)?deepClone(raw.solutionQuality):null,interactionBurden:isPlainObject(raw.interactionBurden)?deepClone(raw.interactionBurden):null,regionalFallback:raw.regionalFallback===true,regionalOutlier:raw.regionalOutlier===true};
|
|||
|
|
for(const cell of specialCells.crossings)if(covered.get(ckey(...cell))!==2||!crossingStateAtCell({paths:solution},puzzle,cell))return null;
|
|||
|
|
const warpClues=repairWarpNumberClues(puzzle,numbers);if(!warpClues)return null;
|
|||
|
|
puzzle.n=warpClues.numbers;puzzle.maxTurns=warpClues.maxTurns;puzzle.totalTurns=warpClues.totalTurns;if(warpClues.repaired)puzzle._warpTurnsRepaired=true;
|
|||
|
|
try{
|
|||
|
|
puzzle.difficulty=solverDifficulty(puzzle,targetLevel);puzzle.level=puzzle.difficulty;puzzle.complexity=isPlainObject(raw.complexity)?deepClone(raw.complexity):null;return puzzle
|
|||
|
|
}catch(_){return null}
|
|||
|
|
}
|
|||
|
|
function puzzleForStorage(puzzle){
|
|||
|
|
return puzzle?{g:puzzle.g,n:puzzle.n,valid:puzzle.valid,obstacles:Array.isArray(puzzle.obstacles)?puzzle.obstacles:[],specialCells:puzzle.specialCells||{crossings:[],warps:[],locks:[]},bounds:puzzle.bounds,axis:puzzle.axis,solution:puzzle.solution,level:puzzle.level,difficulty:puzzle.difficulty,maxTurns:puzzle.maxTurns,totalTurns:puzzle.totalTurns,style:puzzle.style,complexity:puzzle.complexity,regionalTarget:puzzle.regionalTarget,regionalFallback:puzzle.regionalFallback===true,regionalOutlier:puzzle.regionalOutlier===true,uniqueness:puzzle.uniqueness||null,solutionQuality:puzzle.solutionQuality||null,interactionBurden:puzzle.interactionBurden||null}:null;
|
|||
|
|
}
|
|||
|
|
function normalizeMeta(id,raw){
|
|||
|
|
if(!/^B(?:0|[1-9]\d*)$/.test(id)||!isPlainObject(raw))return null;
|
|||
|
|
const x=raw.x,y=raw.y,chunks=raw.chunks;
|
|||
|
|
if(!Number.isSafeInteger(x)||!Number.isSafeInteger(y)||Math.abs(x)>MAX_WORLD_COORD||Math.abs(y)>MAX_WORLD_COORD||!validChunkShape(chunks))return null;
|
|||
|
|
const target=Number.isInteger(raw.targetLevel)?raw.targetLevel:raw.level;
|
|||
|
|
const level=Number.isInteger(raw.level)?raw.level:target;
|
|||
|
|
if(!Number.isInteger(target)||target<1||target>10||!Number.isInteger(level)||level<1||level>10||!Number.isFinite(raw.seed))return null;
|
|||
|
|
const sealedSides=[...new Set((Array.isArray(raw.sealedSides)?raw.sealedSides:[]).filter(side=>['N','S','W','E'].includes(side)))],puzzle=normalizeStoredPuzzle(raw.puzzle,chunks,target);
|
|||
|
|
if(!puzzle)return null;
|
|||
|
|
if(puzzle._warpTurnsRepaired)delete puzzle._warpTurnsRepaired;
|
|||
|
|
return{id,x,y,chunks:chunks.map(q=>[q[0],q[1]]),level:puzzle.difficulty,targetLevel:target,seed:raw.seed>>>0,axis:puzzle.axis,entrySide:['N','S','W','E'].includes(raw.entrySide)?raw.entrySide:null,sealedSides,puzzle,generatorVersion:Number.isInteger(raw.generatorVersion)?raw.generatorVersion:GENERATOR_VERSION,rev:Number.isFinite(raw.rev)&&raw.rev>=0?raw.rev:0,revAuthor:typeof raw.revAuthor==='string'?raw.revAuthor.slice(0,128):''};
|
|||
|
|
}
|
|||
|
|
function mechanicTypesForPuzzle(puzzle){
|
|||
|
|
const types=[];if(puzzle?.specialCells?.warps?.length)types.push('warp');if(puzzle?.specialCells?.locks?.length)types.push('lock');if(puzzle?.specialCells?.crossings?.length)types.push('crossing');return types;
|
|||
|
|
}
|
|||
|
|
function normalizeSnapshot(raw,{quiet=false}={}){
|
|||
|
|
const clean=defaultData();
|
|||
|
|
if(!isPlainObject(raw)){if(!quiet)loadNotices.push('\u4fdd\u5b58\u30c7\u30fc\u30bf\u3092\u521d\u671f\u5316\u3057\u307e\u3057\u305f\u3002');return clean}
|
|||
|
|
if(raw.schema!==SAVE_SCHEMA||raw.worldGeneration!==WORLD_GENERATION){
|
|||
|
|
if(!quiet)loadNotices.push(`\u3053\u306e\u4fdd\u5b58\u5f62\u5f0f\u306b\u306f\u5bfe\u5fdc\u3057\u3066\u3044\u307e\u305b\u3093\uff1a${String(raw.schema)}`);
|
|||
|
|
return clean;
|
|||
|
|
}
|
|||
|
|
const rawMetas=isPlainObject(raw.metas)?raw.metas:{};
|
|||
|
|
const metas=[];
|
|||
|
|
const metaEntries=Object.entries(rawMetas);
|
|||
|
|
if(metaEntries.length>MAX_BOARDS&&!quiet)loadNotices.push('\u76e4\u9762\u6570\u304c\u4e0a\u9650\u3092\u8d85\u3048\u305f\u305f\u3081\u3001\u8d85\u904e\u5206\u3092\u9664\u5916\u3057\u307e\u3057\u305f\u3002');
|
|||
|
|
for(const[id,value]of metaEntries.slice(0,MAX_BOARDS)){const meta=normalizeMeta(id,value);if(meta)metas.push(meta);else if(!quiet)loadNotices.push(`\u7834\u640d\u3057\u305f\u76e4\u9762\u3092\u9664\u5916\u3057\u307e\u3057\u305f\uff1a${id}`)}
|
|||
|
|
metas.sort((a,b)=>a.id==='B0'?-1:b.id==='B0'?1:(b.rev-a.rev)||a.id.localeCompare(b.id,undefined,{numeric:true}));
|
|||
|
|
const occupied=new Set();
|
|||
|
|
for(const meta of metas){
|
|||
|
|
if(meta.chunks.some(([dx,dy])=>occupied.has(key2(meta.x+dx,meta.y+dy)))){if(!quiet)loadNotices.push(`\u91cd\u8907\u3057\u305f\u76e4\u9762\u3092\u9664\u5916\u3057\u307e\u3057\u305f\uff1a${meta.id}`);continue}
|
|||
|
|
clean.metas[meta.id]=meta;
|
|||
|
|
for(const[dx,dy]of meta.chunks)occupied.add(key2(meta.x+dx,meta.y+dy));
|
|||
|
|
}
|
|||
|
|
const rawStates=isPlainObject(raw.states)?raw.states:{};
|
|||
|
|
for(const id of Object.keys(clean.metas))clean.states[id]=normalizeState(rawStates[id]);
|
|||
|
|
const maxId=Math.max(0,...Object.keys(clean.metas).map(id=>Number(id.slice(1))||0));
|
|||
|
|
clean.nextId=Math.max(maxId+1,Number.isInteger(raw.nextId)&&raw.nextId>0?raw.nextId:1);
|
|||
|
|
clean.lastSolveAt=Number.isFinite(raw.lastSolveAt)&&raw.lastSolveAt>0?raw.lastSolveAt:0;
|
|||
|
|
clean.bonusEvents=normalizeBonusEvents(raw.bonusEvents);clean.bonusScore=bonusEventTotal(clean.bonusEvents);clean.bonusScoreVersion=SCORE_VERSION;
|
|||
|
|
clean.timeAttack=normalizeTimeAttackRun(raw.timeAttack);
|
|||
|
|
clean.timeAttackRev=Number.isFinite(raw.timeAttackRev)&&raw.timeAttackRev>=0?raw.timeAttackRev:0;
|
|||
|
|
clean.timeAttackCooldowns=normalizeTimeAttackCooldowns(raw.timeAttackCooldowns);
|
|||
|
|
clean.timeAttackSuggestionsDisabled=raw.timeAttackSuggestionsDisabled===true;
|
|||
|
|
clean.cursorStyle=typeof raw.cursorStyle==='string'?raw.cursorStyle.slice(0,32):'default';
|
|||
|
|
clean.scoreLensEnabled=raw.scoreLensEnabled===true;
|
|||
|
|
clean.debugAllItems=raw.debugAllItems===true;
|
|||
|
|
const seen=new Set(Array.isArray(raw.specialMechanicsSeen)?raw.specialMechanicsSeen.filter(type=>['warp','lock','crossing'].includes(type)):[]);
|
|||
|
|
for(const meta of Object.values(clean.metas))for(const type of mechanicTypesForPuzzle(meta.puzzle))seen.add(type);
|
|||
|
|
clean.specialMechanicsSeen=[...seen].sort();
|
|||
|
|
clean.lastTimeAttack=normalizeTimeAttackResult(raw.lastTimeAttack);
|
|||
|
|
clean.quarantine=isPlainObject(raw.quarantine)?raw.quarantine:{};
|
|||
|
|
clean.gameplayVersion=GAMEPLAY_DATA_VERSION;
|
|||
|
|
clean.clockFloor=Number.isFinite(raw.clockFloor)&&raw.clockFloor>0?raw.clockFloor:0;
|
|||
|
|
clean.cloudProfile=isPlainObject(raw.cloudProfile)&&typeof raw.cloudProfile.playerId==='string'&&typeof raw.cloudProfile.token==='string'?{playerId:raw.cloudProfile.playerId.slice(0,64),token:raw.cloudProfile.token.slice(0,128)}:null;
|
|||
|
|
clean.cloudRevision=Number.isSafeInteger(raw.cloudRevision)&&raw.cloudRevision>=0?raw.cloudRevision:0;
|
|||
|
|
clean.cloudSyncPaused=raw.cloudSyncPaused===true;
|
|||
|
|
clean.playerName=typeof raw.playerName==='string'&&raw.playerName.trim()?raw.playerName.trim().slice(0,24):null;
|
|||
|
|
clean.playerPurchases=normalizePlayerPurchases(raw.playerPurchases);clean.playerEarnedScore=Number.isSafeInteger(raw.playerEarnedScore)&&raw.playerEarnedScore>=0?raw.playerEarnedScore:0;
|
|||
|
|
clean.lastReaction=REACTION_EMOJIS.includes(raw.lastReaction)?raw.lastReaction:'👍';
|
|||
|
|
clean.worldFeedRevision=Number.isSafeInteger(raw.worldFeedRevision)&&raw.worldFeedRevision>=0?raw.worldFeedRevision:0;
|
|||
|
|
clean.cloudPending=normalizeCloudPending(raw.cloudPending);
|
|||
|
|
clean.worldEpoch=validWorldEpoch(raw.worldEpoch)?raw.worldEpoch:null;
|
|||
|
|
clean.globalRev=Number.isFinite(raw.globalRev)&&raw.globalRev>=0?raw.globalRev:0;
|
|||
|
|
clean.globalRevAuthor=typeof raw.globalRevAuthor==='string'?raw.globalRevAuthor.slice(0,128):'';
|
|||
|
|
clean.cameraAnchor=isPlainObject(raw.cameraAnchor)&&Number.isFinite(raw.cameraAnchor.centerX)&&Number.isFinite(raw.cameraAnchor.centerY)&&Number.isFinite(raw.cameraAnchor.scale)?{centerX:raw.cameraAnchor.centerX,centerY:raw.cameraAnchor.centerY,scale:Math.max(MIN_CAMERA_SCALE,Math.min(1.8,raw.cameraAnchor.scale))}:null;
|
|||
|
|
clean.selectedBoardId=typeof raw.selectedBoardId==='string'&&/^B(?:0|[1-9]\d*)$/.test(raw.selectedBoardId)?raw.selectedBoardId:null;
|
|||
|
|
clean.updatedAt=Number.isFinite(raw.updatedAt)&&raw.updatedAt>0?raw.updatedAt:0;
|
|||
|
|
return clean;
|
|||
|
|
}
|
|||
|
|
function preserveRecovery(rawText,reason){
|
|||
|
|
if(!rawText)return false;
|
|||
|
|
const envelope={savedAt:new Date().toISOString(),reason,raw:rawText};volatileRecovery=envelope;void persistRecoveryEnvelope(envelope);
|
|||
|
|
const json=JSON.stringify(envelope);
|
|||
|
|
if(safeLocalSet(recoveryStorageKey,json))return true;
|
|||
|
|
try{sessionStorage.setItem(recoveryStorageKey,json);return true}catch(_){return false}
|
|||
|
|
}
|
|||
|
|
async function preserveRecoveryDurably(rawText,reason){
|
|||
|
|
if(!rawText)throw new Error('Recovery snapshot is empty');
|
|||
|
|
const envelope={savedAt:new Date().toISOString(),reason,raw:rawText},json=JSON.stringify(envelope);volatileRecovery=envelope;
|
|||
|
|
let browserCopy=safeLocalSet(recoveryStorageKey,json);if(!browserCopy)try{sessionStorage.setItem(recoveryStorageKey,json);browserCopy=true}catch(_){}
|
|||
|
|
const databaseCopy=await persistRecoveryEnvelope(envelope);if(!browserCopy&&!databaseCopy)throw new Error('\u30d0\u30c3\u30af\u30a2\u30c3\u30d7\u3092\u4fdd\u5b58\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002');
|
|||
|
|
return{browserCopy,databaseCopy,envelope};
|
|||
|
|
}
|
|||
|
|
function mirrorChunkKey(generation,index){return`${mirrorChunkPrefix}${generation}:${index}`}
|
|||
|
|
function parseMirrorManifest(raw){
|
|||
|
|
if(!raw||raw.charCodeAt(0)!==123)return null;
|
|||
|
|
try{const value=JSON.parse(raw);return value?.format===MIRROR_CHUNK_FORMAT&&typeof value.generation==='string'&&Number.isSafeInteger(value.chunks)&&value.chunks>=0?value:null}catch(_){return null}
|
|||
|
|
}
|
|||
|
|
function readCompactMirrorRaw(){
|
|||
|
|
const manifestRaw=safeLocalGet(storageKey);if(manifestRaw==null)return null;
|
|||
|
|
const manifest=parseMirrorManifest(manifestRaw);if(!manifest)return manifestRaw;
|
|||
|
|
const maxChunks=Math.ceil(LOCAL_MIRROR_MAX_BYTES/MIRROR_CHUNK_BYTES)+8;
|
|||
|
|
if(manifest.chunks>maxChunks||!Number.isFinite(manifest.bytes)||manifest.bytes<0||manifest.bytes>LOCAL_MIRROR_MAX_BYTES)return null;
|
|||
|
|
const chunks=[];let bytes=0;
|
|||
|
|
for(let index=0;index<manifest.chunks;index++){const chunk=safeLocalGet(mirrorChunkKey(manifest.generation,index));if(chunk==null)return null;chunks.push(chunk);bytes+=chunk.length;if(bytes>LOCAL_MIRROR_MAX_BYTES)return null}
|
|||
|
|
if(bytes!==manifest.bytes)return null;return chunks.join('');
|
|||
|
|
}
|
|||
|
|
function clearCompactMirror(){let ok=safeLocalRemove(storageKey);for(const key of safeLocalKeys(mirrorChunkPrefix))ok=safeLocalRemove(key)&&ok;return ok}
|
|||
|
|
function readInitialData(){
|
|||
|
|
const raw=readCompactMirrorRaw();
|
|||
|
|
if(raw!=null){
|
|||
|
|
if(raw.length>MAX_IMPORT_BYTES){preserveRecovery(raw,'Save exceeded safe import size');loadNotices.push('\u4fdd\u5b58\u30c7\u30fc\u30bf\u304c\u8aad\u307f\u8fbc\u307f\u4e0a\u9650\u3092\u8d85\u3048\u3066\u3044\u307e\u3059\u3002');return defaultData()}
|
|||
|
|
try{
|
|||
|
|
const noticeCount=loadNotices.length,snapshot=normalizeSnapshot(JSON.parse(raw));
|
|||
|
|
if(loadNotices.length>noticeCount)preserveRecovery(raw,'Schema recovery');
|
|||
|
|
return snapshot;
|
|||
|
|
}catch(error){preserveRecovery(raw,error?.message||'JSON parse error');loadNotices.push('\u4fdd\u5b58\u30c7\u30fc\u30bf\u3092\u8aad\u307f\u8fbc\u3081\u306a\u3044\u305f\u3081\u3001\u65b0\u3057\u3044\u30d5\u30a3\u30fc\u30eb\u30c9\u3092\u958b\u59cb\u3057\u307e\u3059\u3002');return defaultData()}
|
|||
|
|
}
|
|||
|
|
if(storageAccessError)loadNotices.push('\u30d6\u30e9\u30a6\u30b6\u306e\u4fdd\u5b58\u6a5f\u80fd\u3092\u5229\u7528\u3067\u304d\u307e\u305b\u3093\u3002');
|
|||
|
|
return defaultData();
|
|||
|
|
}
|
|||
|
|
const indexedDbExpectedAtStartup=typeof indexedDB!=='undefined';
|
|||
|
|
let worldDbPromise=null,idbAvailable=indexedDbExpectedAtStartup,idbHealth=indexedDbExpectedAtStartup?'unverified':'unsupported',worldDbAbandoned=false,startupCloudPending=null,startupCloudOutboxLoaded=false,startupWorldEpoch=null,startupWorldControl=null,activeStorageFormat=FIELD_STORAGE_FORMAT,fieldIndexComplete=true,fieldIndexExpectedCount=0,fieldIndexLoadedCount=0,fieldIndexAfterNumber=-1,fieldIndexScanPromise=null,cloudOutboxReady=!cloudApiEnabled;
|
|||
|
|
function requestValue(request){return new Promise((resolve,reject)=>{request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error||new Error('\u4fdd\u5b58\u30c7\u30fc\u30bf\u306e\u8aad\u307f\u53d6\u308a\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002'))})}
|
|||
|
|
function transactionDone(tx){return new Promise((resolve,reject)=>{tx.oncomplete=()=>resolve();tx.onerror=()=>reject(tx.error||new Error('\u4fdd\u5b58\u51e6\u7406\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002'));tx.onabort=()=>reject(tx.error||new Error('\u4fdd\u5b58\u51e6\u7406\u304c\u4e2d\u65ad\u3055\u308c\u307e\u3057\u305f\u3002'))})}
|
|||
|
|
function openWorldDb(){
|
|||
|
|
if(!idbAvailable||worldDbAbandoned)return Promise.reject(new Error('\u30d6\u30e9\u30a6\u30b6\u306e\u4fdd\u5b58\u6a5f\u80fd\u3092\u5229\u7528\u3067\u304d\u307e\u305b\u3093\u3002'));
|
|||
|
|
if(worldDbPromise)return worldDbPromise;
|
|||
|
|
worldDbPromise=new Promise((resolve,reject)=>{
|
|||
|
|
const request=indexedDB.open(worldDbName,IDB_LAYOUT_VERSION);let blockedTimer=0,abandoned=false;
|
|||
|
|
request.onupgradeneeded=event=>{
|
|||
|
|
const db=request.result;
|
|||
|
|
for(const retired of['metas','states','global','recovery','tombstones','outbox'])if(db.objectStoreNames.contains(retired))db.deleteObjectStore(retired);
|
|||
|
|
const controlStore=db.objectStoreNames.contains('control')?request.transaction.objectStore('control'):db.createObjectStore('control',{keyPath:'key'}),
|
|||
|
|
worldsStore=db.objectStoreNames.contains('worlds')?request.transaction.objectStore('worlds'):db.createObjectStore('worlds',{keyPath:'epoch'});
|
|||
|
|
const ensureEpochStore=(name,keyPath)=>{
|
|||
|
|
const store=db.objectStoreNames.contains(name)?request.transaction.objectStore(name):db.createObjectStore(name,{keyPath});
|
|||
|
|
if(!store.indexNames.contains('epoch'))store.createIndex('epoch','epoch',{unique:false});
|
|||
|
|
return store;
|
|||
|
|
};
|
|||
|
|
const boardIndexStore=ensureEpochStore('boardIndex',['epoch','id']);if(!boardIndexStore.indexNames.contains('epochNumber'))boardIndexStore.createIndex('epochNumber',['epoch','number'],{unique:true});
|
|||
|
|
ensureEpochStore('boardPuzzles',['epoch','id']);ensureEpochStore('boardStates',['epoch','id']);
|
|||
|
|
ensureEpochStore('tombstonesV2',['epoch','id']);ensureEpochStore('recoveryV2',['epoch','key']);ensureEpochStore('outboxV2',['epoch','key']);
|
|||
|
|
if(event.oldVersion<IDB_LAYOUT_VERSION){
|
|||
|
|
const epoch=createWorldEpoch(),fresh=defaultData();fresh.worldEpoch=epoch;
|
|||
|
|
const global=globalForStorage(fresh),now=Date.now();
|
|||
|
|
rememberWorldEpoch(epoch);
|
|||
|
|
controlStore.put({key:'active',activeFormat:FIELD_STORAGE_FORMAT,activeEpoch:epoch,activationId:`fresh:${epoch}`,activationVerified:true,switchedAt:now});
|
|||
|
|
worldsStore.put({epoch,status:'active',schema:SAVE_SCHEMA,worldGeneration:WORLD_GENERATION,global,boardCount:0,solvedCount:0,score:0,bounds:{minX:0,minY:0,maxX:1,maxY:1},approximateBytes:0,createdAt:now,source:{kind:'fresh'}});
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
request.onsuccess=()=>{clearTimeout(blockedTimer);const db=request.result;if(abandoned||worldDbAbandoned){db.close();reject(new Error('IndexedDB startup was abandoned'));return}db.onversionchange=()=>db.close();idbHealth='ready';resolve(db)};
|
|||
|
|
request.onerror=()=>{clearTimeout(blockedTimer);reject(request.error||new Error('\u4fdd\u5b58\u30c7\u30fc\u30bf\u3092\u958b\u3051\u307e\u305b\u3093\u3067\u3057\u305f\u3002'))};
|
|||
|
|
request.onblocked=()=>{console.warn('BEND FIELD: IndexedDB upgrade is blocked by another tab');if(!blockedTimer)blockedTimer=setTimeout(()=>{abandoned=true;reject(new Error('IndexedDB upgrade remained blocked by another tab'))},IDB_STARTUP_TIMEOUT)};
|
|||
|
|
}).catch(error=>{idbAvailable=false;idbHealth=worldDbAbandoned?'uncertain':'failed';cloudOutboxReady=!cloudApiEnabled;worldDbPromise=null;throw error});
|
|||
|
|
return worldDbPromise;
|
|||
|
|
}
|
|||
|
|
function cloudPendingFromOutboxRows(rows){
|
|||
|
|
const pending={metaIds:[],stateIds:[],deleted:[],globalChanged:false};
|
|||
|
|
for(const row of rows||[]){if(row?.type==='global')pending.globalChanged=true;else if(row?.id&&row.type==='meta')pending.metaIds.push(row.id);else if(row?.id&&row.type==='state')pending.stateIds.push(row.id);else if(row?.id&&row.type==='deleted')pending.deleted.push(row.id)}
|
|||
|
|
return normalizeCloudPending(pending);
|
|||
|
|
}
|
|||
|
|
function epochKeyRange(epoch){return IDBKeyRange.bound([epoch,''],[epoch,'\uffff'])}
|
|||
|
|
async function readActiveWorldControl(db=null){
|
|||
|
|
const target=db||await openWorldDb(),tx=target.transaction('control','readonly'),done=transactionDone(tx),row=await requestValue(tx.objectStore('control').get('active'));await done;
|
|||
|
|
return row||null;
|
|||
|
|
}
|
|||
|
|
async function rollbackUnverifiedWorld(db,control){
|
|||
|
|
if(!control||control.activationVerified!==false||!validWorldEpoch(control.previousEpoch))return null;
|
|||
|
|
const tx=db.transaction(['control','worlds'],'readwrite'),done=transactionDone(tx),controlStore=tx.objectStore('control'),worldsStore=tx.objectStore('worlds'),
|
|||
|
|
[current,previous]=await Promise.all([requestValue(worldsStore.get(control.activeEpoch)),requestValue(worldsStore.get(control.previousEpoch))]);
|
|||
|
|
if(!previous){try{tx.abort()}catch(_){}await done.catch(()=>{});return null}
|
|||
|
|
if(current){current.status='garbage';worldsStore.put(current)}previous.status='active';worldsStore.put(previous);
|
|||
|
|
const restored={key:'active',activeFormat:FIELD_STORAGE_FORMAT,activeEpoch:previous.epoch,activationId:`rollback:${sessionId}:${Date.now()}`,activationVerified:true,switchedAt:trustedNow()};
|
|||
|
|
controlStore.put(restored);await done;rememberWorldEpoch(previous.epoch);return restored;
|
|||
|
|
}
|
|||
|
|
async function loadV2SnapshotFromDb(db,control){
|
|||
|
|
const epoch=control?.activeEpoch;if(!validWorldEpoch(epoch))throw new Error('The active field epoch is invalid.');
|
|||
|
|
const tx=db.transaction(['worlds','boardIndex','outboxV2','recoveryV2','tombstonesV2'],'readonly'),done=transactionDone(tx),
|
|||
|
|
[world,firstIndexRows,outboxRows,recoveryRows,tombstoneRows]=await Promise.all([
|
|||
|
|
requestValue(tx.objectStore('worlds').get(epoch)),requestValue(tx.objectStore('boardIndex').index('epochNumber').getAll(IDBKeyRange.bound([epoch,0],[epoch,Number.MAX_SAFE_INTEGER]),512)),
|
|||
|
|
requestValue(tx.objectStore('outboxV2').getAll(epochKeyRange(epoch))),requestValue(tx.objectStore('recoveryV2').getAll(epochKeyRange(epoch))),
|
|||
|
|
requestValue(tx.objectStore('tombstonesV2').getAll(epochKeyRange(epoch)))
|
|||
|
|
]);await done;
|
|||
|
|
if(!world||world.status!=='active'&&world.status!=='rollback')throw new Error('The active field record is missing.');
|
|||
|
|
const selectedId=typeof world.global?.selectedBoardId==='string'&&/^B(?:0|[1-9]\d*)$/.test(world.global.selectedBoardId)?world.global.selectedBoardId:null,indexRows=[...firstIndexRows];
|
|||
|
|
if(selectedId&&!firstIndexRows.some(row=>row.id===selectedId)){
|
|||
|
|
const selectedTx=db.transaction('boardIndex','readonly'),selectedDone=transactionDone(selectedTx),selectedIndex=await requestValue(selectedTx.objectStore('boardIndex').get([epoch,selectedId]));await selectedDone;if(selectedIndex)indexRows.push(selectedIndex);
|
|||
|
|
}
|
|||
|
|
const tombstones=new Map(tombstoneRows.map(row=>[row.id,row])),
|
|||
|
|
snapshot=normalizeSnapshot({...(world.global||{}),schema:SAVE_SCHEMA,worldGeneration:WORLD_GENERATION,worldEpoch:epoch,metas:{},states:{}},{quiet:true});
|
|||
|
|
boardIndexSummaries.clear();
|
|||
|
|
for(const index of indexRows){
|
|||
|
|
const tombstone=tombstones.get(index.id);
|
|||
|
|
if(tombstone&&compareRevisionVersions(tombstone,{rev:Math.max(index.metaRev||0,index.stateRev||0),revAuthor:(index.metaRev||0)>=(index.stateRev||0)?index.metaRevAuthor||'':index.stateRevAuthor||''})>=0)continue;
|
|||
|
|
if(!/^B(?:0|[1-9]\d*)$/.test(index.id)||!validChunkShape(index.chunks))throw new Error(`The stored field index is invalid for ${index.id}.`);
|
|||
|
|
snapshot.metas[index.id]={id:index.id,x:index.x,y:index.y,chunks:index.chunks.map(chunk=>[chunk[0],chunk[1]]),level:index.level,targetLevel:index.targetLevel,seed:index.seed,axis:index.axis,entrySide:index.entrySide||null,sealedSides:[...(index.sealedSides||[])],puzzle:null,generatorVersion:GENERATOR_VERSION,rev:index.metaRev||0,revAuthor:index.metaRevAuthor||index.revAuthor||'',_summaryOnly:true};
|
|||
|
|
snapshot.states[index.id]=summaryStateFromIndex(index);
|
|||
|
|
}
|
|||
|
|
let maxId=0,scanAfterNumber=-1;for(const index of indexRows)maxId=Math.max(maxId,index.number||Number(index.id.slice(1))||0);for(const index of firstIndexRows)scanAfterNumber=Math.max(scanAfterNumber,index.number||Number(index.id.slice(1))||0);
|
|||
|
|
snapshot.nextId=Math.max(snapshot.nextId||1,maxId+1);snapshot.solved=world.solvedCount||0;snapshot.score=world.score||0;
|
|||
|
|
fieldIndexLoadedCount=Object.keys(snapshot.metas).length;fieldIndexExpectedCount=Math.max(fieldIndexLoadedCount,world.boardCount||0);fieldIndexAfterNumber=scanAfterNumber;fieldIndexComplete=fieldIndexLoadedCount>=fieldIndexExpectedCount;
|
|||
|
|
startupCloudPending=cloudPendingFromOutboxRows(outboxRows);startupCloudOutboxLoaded=true;startupWorldEpoch=epoch;rememberWorldEpoch(epoch);
|
|||
|
|
const coverage=new Map(),databaseJournals=[];for(const row of recoveryRows){if(typeof row?.key!=='string')continue;if(row.key.startsWith('covered:'))coverage.set(row.key.slice(8),row.value?.seq??-1);else if(row.key.startsWith('journal:')&&row.value)databaseJournals.push({...row.value,_dbKey:row.key})}
|
|||
|
|
return{snapshot,coverage,tombstones,databaseJournals,worldEpoch:epoch};
|
|||
|
|
}
|
|||
|
|
async function readV2IndexPage(epoch,afterNumber,limit=512){
|
|||
|
|
const db=await openWorldDb(),tx=db.transaction('boardIndex','readonly'),done=transactionDone(tx),
|
|||
|
|
range=IDBKeyRange.bound([epoch,Math.max(0,afterNumber+1)],[epoch,Number.MAX_SAFE_INTEGER]),
|
|||
|
|
rows=await requestValue(tx.objectStore('boardIndex').index('epochNumber').getAll(range,limit));await done;return rows;
|
|||
|
|
}
|
|||
|
|
function installV2IndexSummary(index){
|
|||
|
|
if(!index||!/^B(?:0|[1-9]\d*)$/.test(index.id)||!validChunkShape(index.chunks))throw new Error(`The stored field index is invalid for ${index?.id||'?'}.`);
|
|||
|
|
if(data.metas[index.id])return false;
|
|||
|
|
const meta={id:index.id,x:index.x,y:index.y,chunks:index.chunks.map(chunk=>[chunk[0],chunk[1]]),level:index.level,targetLevel:index.targetLevel,seed:index.seed,axis:index.axis,entrySide:index.entrySide||null,sealedSides:[...(index.sealedSides||[])],puzzle:null,generatorVersion:GENERATOR_VERSION,rev:index.metaRev||0,revAuthor:index.metaRevAuthor||index.revAuthor||'',_summaryOnly:true},
|
|||
|
|
state=summaryStateFromIndex(index);
|
|||
|
|
data.metas[index.id]=meta;data.states[index.id]=state;fieldIndexLoadedCount++;normalizedStateObjects.add(state);
|
|||
|
|
for(const[dx,dy]of meta.chunks){const key=key2(meta.x+dx,meta.y+dy),owner=occupancy.get(key);if(owner&&owner!==meta.id)throw new Error(`Stored field indexes overlap: ${owner} and ${meta.id}`);occupancy.set(key,meta.id)}
|
|||
|
|
refreshClosedVoidAroundMeta(meta);worldGeometryRevision++;fieldBoundsCache=null;data.nextId=Math.max(data.nextId||1,(index.number||Number(index.id.slice(1))||0)+1);return true;
|
|||
|
|
}
|
|||
|
|
async function completeV2IndexScan(){
|
|||
|
|
if(fieldIndexComplete||activeStorageFormat!==FIELD_STORAGE_FORMAT)return 0;
|
|||
|
|
if(fieldIndexScanPromise)return fieldIndexScanPromise;
|
|||
|
|
fieldIndexScanPromise=(async()=>{
|
|||
|
|
let added=0,pages=0;
|
|||
|
|
while(!fieldIndexComplete){
|
|||
|
|
const page=await readV2IndexPage(data.worldEpoch,fieldIndexAfterNumber,512);if(!page.length){fieldIndexComplete=fieldIndexLoadedCount>=fieldIndexExpectedCount;if(!fieldIndexComplete)throw new Error('The field index ended before its declared board count.');break}
|
|||
|
|
for(const index of page){fieldIndexAfterNumber=Math.max(fieldIndexAfterNumber,index.number||Number(index.id.slice(1))||0);if(installV2IndexSummary(index))added++}
|
|||
|
|
fieldIndexComplete=fieldIndexLoadedCount>=fieldIndexExpectedCount;pages++;worldCountEl.textContent=String(fieldIndexLoadedCount);
|
|||
|
|
if(fieldIndexComplete||pages%8===0){scheduleMinimap(true);scheduleWorldOverview(true)}
|
|||
|
|
if(!fieldIndexComplete)await new Promise(resolve=>setTimeout(resolve,0));
|
|||
|
|
}
|
|||
|
|
if(fieldIndexComplete){orphanPruneDirty=true;invalidateEconomyCaches();statsDirty=true;pruneAndCount();invalidateWorldPresentation();perfCount('fieldIndexScans')}
|
|||
|
|
return added;
|
|||
|
|
})().finally(()=>{fieldIndexScanPromise=null});
|
|||
|
|
return fieldIndexScanPromise;
|
|||
|
|
}
|
|||
|
|
async function loadSnapshotFromDb(journals=[]){
|
|||
|
|
const db=await openWorldDb(),control=await readActiveWorldControl(db);startupWorldControl=control;
|
|||
|
|
if(control?.activeFormat!==FIELD_STORAGE_FORMAT)throw new Error('この版では旧保存形式を読み込めません。');
|
|||
|
|
activeStorageFormat=FIELD_STORAGE_FORMAT;
|
|||
|
|
try{return await loadV2SnapshotFromDb(db,control)}
|
|||
|
|
catch(error){
|
|||
|
|
const restored=await rollbackUnverifiedWorld(db,control).catch(()=>null);
|
|||
|
|
if(restored){startupWorldControl=restored;loadNotices.push('\u8aad\u307f\u8fbc\u307f\u306b\u5931\u6557\u3057\u305f\u305f\u3081\u3001\u524d\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u306b\u623b\u3057\u307e\u3057\u305f\u3002');return loadV2SnapshotFromDb(db,restored)}
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
async function loadCloudOutboxFromDb(){
|
|||
|
|
if(!idbAvailable||!cloudApiEnabled)return normalizeCloudPending(null);
|
|||
|
|
const epoch=validWorldEpoch(data?.worldEpoch)?data.worldEpoch:startupWorldEpoch;if(!validWorldEpoch(epoch))return normalizeCloudPending(null);
|
|||
|
|
const db=await openWorldDb(),tx=db.transaction('outboxV2','readonly'),done=transactionDone(tx),rows=await requestValue(tx.objectStore('outboxV2').getAll(epochKeyRange(epoch)));await done;
|
|||
|
|
return cloudPendingFromOutboxRows(rows);
|
|||
|
|
}
|
|||
|
|
function withStartupTimeout(promise,ms,label){
|
|||
|
|
let timer=null;
|
|||
|
|
return Promise.race([
|
|||
|
|
Promise.resolve(promise),
|
|||
|
|
new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error(label)),ms)})
|
|||
|
|
]).finally(()=>clearTimeout(timer));
|
|||
|
|
}
|
|||
|
|
let recoveryJournalsToCover=[];
|
|||
|
|
const recoveredDeletionTombstones=new Map();
|
|||
|
|
const startupRecoveredMetaIds=new Set(),startupRecoveredStateIds=new Set(),startupRecoveredDeletedIds=new Set();
|
|||
|
|
function setRecoveryJournalsToCover(journals){
|
|||
|
|
recoveryJournalsToCover=[...(journals||[])];recoveredDeletionTombstones.clear();
|
|||
|
|
for(const journal of recoveryJournalsToCover)for(const entry of journal.deleted||[]){const id=entry?.id,tombstone={id,rev:Number.isFinite(entry?.rev)?entry.rev:0,revAuthor:typeof entry?.revAuthor==='string'?entry.revAuthor:journal.sessionId},current=recoveredDeletionTombstones.get(id);if(id&&(!current||compareRevisionVersions(tombstone,current)>0))recoveredDeletionTombstones.set(id,tombstone)}
|
|||
|
|
}
|
|||
|
|
function readRecoveryJournals(){
|
|||
|
|
const keys=safeLocalKeys(recoveryJournalPrefix),journals=[];
|
|||
|
|
for(const key of new Set(keys)){const raw=safeLocalGet(key);if(!raw||raw.length>LOCAL_MIRROR_MAX_BYTES)continue;try{const journal=JSON.parse(raw);if(journal?.schema===SAVE_SCHEMA&&journal?.worldGeneration===WORLD_GENERATION&&Number.isFinite(journal.updatedAt))journals.push({...journal,_storageKey:key})}catch(_){}}
|
|||
|
|
return journals.filter(journal=>typeof journal.sessionId==='string'&&Number.isSafeInteger(journal.seq)).sort((a,b)=>(a.updatedAt||0)-(b.updatedAt||0));
|
|||
|
|
}
|
|||
|
|
function mergeRecoveryJournal(base,journal){
|
|||
|
|
if(!journal||!base)return base;
|
|||
|
|
const global=isPlainObject(journal.global)?deepClone(journal.global):null,newerGlobal=(journal.updatedAt||0)>=(base.updatedAt||0),
|
|||
|
|
merged={...deepClone(base),...(newerGlobal&&global?global:{}),metas:deepClone(base.metas||{}),states:deepClone(base.states||{})};
|
|||
|
|
if(!newerGlobal&&global){
|
|||
|
|
merged.nextId=Math.max(merged.nextId||1,global.nextId||1);merged.clockFloor=Math.max(merged.clockFloor||0,global.clockFloor||0);merged.cloudRevision=Math.max(merged.cloudRevision||0,global.cloudRevision||0);
|
|||
|
|
merged.bonusEvents=deepClone(merged.bonusEvents||{});for(const[id,value]of Object.entries(global.bonusEvents||{}))merged.bonusEvents[id]=Math.max(merged.bonusEvents[id]||0,value||0);
|
|||
|
|
const basePending=normalizeCloudPending(merged.cloudPending),journalPending=normalizeCloudPending(global.cloudPending);merged.cloudPending={metaIds:[...new Set([...basePending.metaIds,...journalPending.metaIds])],stateIds:[...new Set([...basePending.stateIds,...journalPending.stateIds])],deleted:[...new Set([...basePending.deleted,...journalPending.deleted])],globalChanged:basePending.globalChanged||journalPending.globalChanged};
|
|||
|
|
if((global.timeAttackRev||0)>(merged.timeAttackRev||0)){merged.timeAttack=global.timeAttack;merged.timeAttackRev=global.timeAttackRev;merged.timeAttackCooldowns=global.timeAttackCooldowns;merged.lastTimeAttack=global.lastTimeAttack||merged.lastTimeAttack}
|
|||
|
|
}
|
|||
|
|
for(const row of journal.metas||[]){
|
|||
|
|
if(!row?.id)continue;const current=merged.metas[row.id];if(!current||compareRevisionVersions(row,current)>=0)merged.metas[row.id]=deepClone(row);
|
|||
|
|
}
|
|||
|
|
for(const entry of journal.deleted||[]){const id=entry?.id,tombstone={rev:Number.isFinite(entry?.rev)?entry.rev:0,revAuthor:typeof entry?.revAuthor==='string'?entry.revAuthor:journal.sessionId},current=merged.metas[id],state=merged.states[id];if(id&&compareRevisionVersions(tombstone,current)>0&&compareRevisionVersions(tombstone,state)>0){delete merged.metas[id];delete merged.states[id]}}
|
|||
|
|
for(const row of journal.states||[]){
|
|||
|
|
if(!row?.id||!merged.metas[row.id])continue;merged.states[row.id]=mergeBoardStates(merged.states[row.id],normalizeState(row.value));
|
|||
|
|
}
|
|||
|
|
merged.updatedAt=Math.max(base.updatedAt||0,journal.updatedAt||0);return normalizeSnapshot(merged,{quiet:true});
|
|||
|
|
}
|
|||
|
|
function mergeRecoveryJournals(base,journals){let merged=base;for(const journal of journals||[])merged=mergeRecoveryJournal(merged,journal);return merged}
|
|||
|
|
function combineRecoveryJournals(...groups){
|
|||
|
|
const merged=new Map();for(const journal of groups.flat().filter(journal=>journal&&typeof journal.sessionId==='string'&&Number.isSafeInteger(journal.seq))){const key=`${journal.sessionId}:${journal.seq}`,current=merged.get(key);merged.set(key,current?{...current,...journal,_storageKey:current._storageKey||journal._storageKey,_dbKey:current._dbKey||journal._dbKey}:journal)}
|
|||
|
|
return[...merged.values()].sort((a,b)=>(a.updatedAt||0)-(b.updatedAt||0));
|
|||
|
|
}
|
|||
|
|
function mergeV2RecoveryJournals(base,journals){
|
|||
|
|
const merged=base;
|
|||
|
|
for(const journal of journals||[]){
|
|||
|
|
if(journal.global&&(journal.updatedAt||0)>=(merged.updatedAt||0)){
|
|||
|
|
for(const key of['bonusEvents','clockFloor','cloudProfile','cloudRevision','cloudSyncPaused','nextId','lastSolveAt','timeAttack','timeAttackRev','timeAttackCooldowns','lastTimeAttack','specialMechanicsSeen','updatedAt'])if(Object.prototype.hasOwnProperty.call(journal.global,key))merged[key]=deepClone(journal.global[key]);
|
|||
|
|
}
|
|||
|
|
for(const row of journal.metas||[]){if(!row?.id)continue;const normalized=normalizeMeta(row.id,row),current=merged.metas[row.id];if(normalized&&(!current||compareRevisionVersions(normalized,current)>=0)){merged.metas[row.id]=normalized;startupRecoveredMetaIds.add(row.id)}}
|
|||
|
|
for(const entry of journal.deleted||[]){const id=entry?.id;if(!id)continue;delete merged.metas[id];delete merged.states[id];startupRecoveredDeletedIds.add(id)}
|
|||
|
|
for(const row of journal.states||[]){if(!row?.id||!merged.metas[row.id])continue;const normalized=normalizeState(row.value),current=merged.states[row.id];if(!current||compareRevisionVersions(normalized,current)>=0){merged.states[row.id]=normalized;startupRecoveredStateIds.add(row.id)}}
|
|||
|
|
merged.updatedAt=Math.max(merged.updatedAt||0,journal.updatedAt||0);
|
|||
|
|
}
|
|||
|
|
return merged;
|
|||
|
|
}
|
|||
|
|
async function deleteRecoveryCoverageMarkers(sessionIds){
|
|||
|
|
const ids=[...new Set((sessionIds||[]).filter(Boolean))];if(!idbAvailable||!ids.length)return false;
|
|||
|
|
try{
|
|||
|
|
if(!validWorldEpoch(data?.worldEpoch))return false;
|
|||
|
|
const db=await openWorldDb(),tx=db.transaction('recoveryV2','readwrite'),done=transactionDone(tx),store=tx.objectStore('recoveryV2');
|
|||
|
|
for(const id of ids)store.delete([data.worldEpoch,`covered:${id}`]);await done;return true;
|
|||
|
|
}catch(_){return false}
|
|||
|
|
}
|
|||
|
|
function attachWorldEpoch(snapshot,epoch){
|
|||
|
|
const target=snapshot||defaultData(),resolved=validWorldEpoch(epoch)?epoch:validWorldEpoch(target.worldEpoch)?target.worldEpoch:storedWorldEpoch()||createWorldEpoch();
|
|||
|
|
target.worldEpoch=resolved;rememberWorldEpoch(resolved);return target;
|
|||
|
|
}
|
|||
|
|
function abandonWorldDatabase(){
|
|||
|
|
if(!indexedDbExpectedAtStartup)return;worldDbAbandoned=true;idbAvailable=false;idbHealth='uncertain';cloudOutboxReady=!cloudApiEnabled;cloudAvailable=false;
|
|||
|
|
const pending=worldDbPromise;if(pending)void pending.then(db=>{try{db.close()}catch(_){}},()=>{});worldDbPromise=null;
|
|||
|
|
}
|
|||
|
|
async function readInitialDataAsync(){
|
|||
|
|
const boardCount=snapshot=>Object.keys(snapshot?.metas||{}).length;
|
|||
|
|
// The current IndexedDB format is authoritative. Per-session recovery
|
|||
|
|
// journals cover writes that did not reach the active epoch.
|
|||
|
|
const rawMirror=readInitialData(),allJournals=readRecoveryJournals();
|
|||
|
|
if(!idbAvailable){
|
|||
|
|
const epoch=rawMirror.worldEpoch||storedWorldEpoch()||createWorldEpoch(),journals=allJournals.filter(journal=>!journal.worldEpoch||journal.worldEpoch===epoch),fallbackMirror=attachWorldEpoch(mergeRecoveryJournals(rawMirror,journals),epoch),fallbackBoards=boardCount(fallbackMirror);
|
|||
|
|
setRecoveryJournalsToCover(journals);return fallbackMirror
|
|||
|
|
}
|
|||
|
|
const dbLoad=loadSnapshotFromDb(allJournals);
|
|||
|
|
try{
|
|||
|
|
const bundle=await withStartupTimeout(dbLoad,IDB_STARTUP_TIMEOUT,'IndexedDB startup timed out'),epoch=bundle?.worldEpoch||bundle?.snapshot?.worldEpoch||rawMirror.worldEpoch||storedWorldEpoch()||createWorldEpoch(),
|
|||
|
|
journals=combineRecoveryJournals(allJournals,bundle?.databaseJournals||[]).filter(journal=>!journal.worldEpoch||journal.worldEpoch===epoch),coverage=bundle?.coverage||new Map(),
|
|||
|
|
loaded=bundle&&Object.prototype.hasOwnProperty.call(bundle,'snapshot')?bundle.snapshot:bundle,
|
|||
|
|
uncovered=journals.filter(journal=>!journal.sessionId||!Number.isSafeInteger(journal.seq)||(coverage.get(journal.sessionId)??-1)<journal.seq);
|
|||
|
|
for(const journal of journals)if(!uncovered.includes(journal))safeLocalRemove(journal._storageKey);
|
|||
|
|
setRecoveryJournalsToCover(uncovered);
|
|||
|
|
return attachWorldEpoch(mergeV2RecoveryJournals(loaded||defaultData(),uncovered),epoch);
|
|||
|
|
}catch(error){
|
|||
|
|
const epoch=rawMirror.worldEpoch||storedWorldEpoch()||createWorldEpoch(),journals=allJournals.filter(journal=>!journal.worldEpoch||journal.worldEpoch===epoch),fallbackMirror=attachWorldEpoch(mergeRecoveryJournals(rawMirror,journals),epoch),fallbackBoards=boardCount(fallbackMirror);
|
|||
|
|
if(!fallbackBoards)try{const bundle=await withStartupTimeout(dbLoad,4000,'IndexedDB recovery timed out'),recovered=bundle&&Object.prototype.hasOwnProperty.call(bundle,'snapshot')?bundle.snapshot:bundle,dbJournals=combineRecoveryJournals(journals,bundle?.databaseJournals||[]);if(recovered){setRecoveryJournalsToCover(dbJournals);return attachWorldEpoch(mergeV2RecoveryJournals(recovered,dbJournals),bundle?.worldEpoch||epoch)}}catch(_){}
|
|||
|
|
abandonWorldDatabase();
|
|||
|
|
loadNotices.push(fallbackBoards?'\u76f4\u8fd1\u306e\u30d0\u30c3\u30af\u30a2\u30c3\u30d7\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002':'\u4fdd\u5b58\u30c7\u30fc\u30bf\u3092\u78ba\u8a8d\u3067\u304d\u306a\u3044\u305f\u3081\u3001\u30ed\u30fc\u30ab\u30eb\u30c7\u30fc\u30bf\u3067\u8d77\u52d5\u3057\u307e\u3059\u3002');
|
|||
|
|
console.warn('BEND FIELD: IndexedDB startup fallback',error);
|
|||
|
|
setRecoveryJournalsToCover(journals);
|
|||
|
|
return fallbackMirror;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
async function persistRecoveryEnvelope(envelope){
|
|||
|
|
if(!idbAvailable||!envelope||!validWorldEpoch(data?.worldEpoch))return false;
|
|||
|
|
try{const db=await openWorldDb(),tx=db.transaction('recoveryV2','readwrite'),done=transactionDone(tx);tx.objectStore('recoveryV2').put({epoch:data.worldEpoch,key:'latest',value:envelope});await done;return true}catch(_){return false}
|
|||
|
|
}
|
|||
|
|
async function readRecoveryEnvelope(){
|
|||
|
|
if(idbAvailable&&validWorldEpoch(data?.worldEpoch))try{const db=await openWorldDb(),tx=db.transaction('recoveryV2','readonly'),done=transactionDone(tx),row=await requestValue(tx.objectStore('recoveryV2').get([data.worldEpoch,'latest']));await done;if(row?.value)return row.value}catch(_){}
|
|||
|
|
const raw=safeLocalGet(recoveryStorageKey);return raw?parseJsonOrRaw(raw):volatileRecovery;
|
|||
|
|
}
|
|||
|
|
async function clearDatabaseWorld(newEpoch=createWorldEpoch()){
|
|||
|
|
if(!validWorldEpoch(newEpoch))throw new Error('Invalid world epoch');if(!idbAvailable){rememberWorldEpoch(newEpoch);return newEpoch}
|
|||
|
|
const db=await openWorldDb();
|
|||
|
|
const expected=await activeWorldExpectation(),fresh=defaultData();fresh.worldEpoch=newEpoch;fresh.globalRev=nextRevision();fresh.globalRevAuthor=sessionId;fresh.updatedAt=trustedNow();
|
|||
|
|
try{
|
|||
|
|
const chunks=[[0,0]],puzzle=normalizeStoredPuzzle(deepClone(STARTER_PUZZLE),chunks,1);if(!puzzle)throw new Error('The origin puzzle could not be created.');
|
|||
|
|
const revision=nextRevision(),meta={id:'B0',x:0,y:0,chunks,level:puzzle.difficulty,targetLevel:1,seed:STARTER_SEED,axis:puzzle.axis,entrySide:null,sealedSides:[],puzzle,generatorVersion:GENERATOR_VERSION,rev:revision,revAuthor:sessionId},state=normalizeState(null);state.rev=nextRevision();state.revAuthor=sessionId;fresh.metas.B0=meta;fresh.states.B0=state;fresh.nextId=1;
|
|||
|
|
await writeStagedBoardBatch(newEpoch,[{meta,state}]);const world={epoch:newEpoch,status:'ready',schema:SAVE_SCHEMA,worldGeneration:WORLD_GENERATION,global:globalForStorage(fresh),boardCount:1,solvedCount:0,score:0,bounds:{minX:0,minY:0,maxX:1,maxY:1},approximateBytes:JSON.stringify(meta).length+JSON.stringify(state).length,createdAt:trustedNow(),source:{kind:'reset'}},
|
|||
|
|
tx=db.transaction('worlds','readwrite'),done=transactionDone(tx);tx.objectStore('worlds').put(world);await done;await validateStagedWorldV2(newEpoch,{expectedBoardCount:1,strict:true});await activateReadyWorldV2({epoch:newEpoch,expected,world},{kind:'reset'});rememberWorldEpoch(newEpoch);return newEpoch;
|
|||
|
|
}catch(error){await deleteV2Epoch(newEpoch).catch(()=>{});throw error}
|
|||
|
|
}
|
|||
|
|
let data=defaultData();
|
|||
|
|
const rendered=new Map(),staticRendered=new Map();
|
|||
|
|
const boardIndexSummaries=new Map(),hydratedBoardLru=new Map();
|
|||
|
|
let hydratedBoardBytes=0;
|
|||
|
|
let occupancy=new Map(),closedVoidKeys=new Set(),activeBoard=null,hudBoardId=null,toastTimer=null,saveTimer=null,lastRevision=Date.now()*1000;
|
|||
|
|
const dirtyMetaIds=new Set(),dirtyStateIds=new Set(),deletedBoardIds=new Set(),deletedBoardRevisions=new Map(),normalizedStateObjects=new WeakSet();
|
|||
|
|
const deletedBoardAuthors=new Map();
|
|||
|
|
const stateStatSignatures=new Map(),stateEconomySignatures=new Map();
|
|||
|
|
const cloudJournalMetaIds=new Set(),cloudJournalStateIds=new Set(),cloudJournalDeletedIds=new Set(),cloudOutboxDeleteKeys=new Set();
|
|||
|
|
let cloudJournalGlobalChanged=false,cloudJournalChangeSeq=0,cloudApplyingRemote=false,globalDirty=false,globalChangeSeq=0,worldSignalSeq=0,persistQueue=Promise.resolve(),lifecyclePersistenceSuppressed=false,statsDirty=true,cachedStats={solved:0,score:0,earned:0},inventoryCache=null,spentScoreCache=null;
|
|||
|
|
let minimapFrame=0,minimapDirty=true,minimapLongSegments=0,minimapLastDraw=0,minimapDelayTimer=0,minimapRectCache=null;
|
|||
|
|
let overviewFrame=0,overviewDirty=true,overviewLastDraw=0,overviewDelayTimer=0;
|
|||
|
|
const minimapBase=document.createElement('canvas'),overviewBase=document.createElement('canvas');
|
|||
|
|
let overviewCache=null;
|
|||
|
|
const OVERVIEW_CACHE_OVERSCAN_PX=192;
|
|||
|
|
const adjacencyCache=new Map();
|
|||
|
|
let lineGraphRevision=0,lineGraphCacheRevision=-1,lineComponentCache=new Map(),lineWidthCache=new Map(),lineMinimapGeometryCache=new WeakMap();
|
|||
|
|
let minimapWorldRevision=0,worldGeometryRevision=0,minimapCache=null,minimapPointerState=null,fieldBoundsCache=null,orphanPruneDirty=true;
|
|||
|
|
const perfSamples=new Map(),perfCounters=Object.create(null),perfGauges=Object.create(null);
|
|||
|
|
function perfNow(){return globalThis.performance?.now?.()||Date.now()}
|
|||
|
|
let perfResetAt=perfNow();
|
|||
|
|
function perfStart(){return perfNow()}
|
|||
|
|
function perfObserve(name,value){
|
|||
|
|
if(!Number.isFinite(value)||value<0)return value;
|
|||
|
|
const samples=perfSamples.get(name)||[];samples.push(value);if(samples.length>240)samples.splice(0,samples.length-240);perfSamples.set(name,samples);return value;
|
|||
|
|
}
|
|||
|
|
function perfEnd(name,started){
|
|||
|
|
return perfObserve(name,Math.max(0,perfNow()-started));
|
|||
|
|
}
|
|||
|
|
function perfCount(name,amount=1){perfCounters[name]=(perfCounters[name]||0)+amount}
|
|||
|
|
function perfGauge(name,value){perfGauges[name]=value}
|
|||
|
|
function perfSnapshot(){
|
|||
|
|
const timings={};
|
|||
|
|
for(const[name,samples]of perfSamples){const sorted=[...samples].sort((a,b)=>a-b),pick=q=>sorted.length?sorted[Math.min(sorted.length-1,Math.floor((sorted.length-1)*q))]:0;timings[name]={count:sorted.length,p50:pick(.5),p95:pick(.95),max:sorted[sorted.length-1]||0}}
|
|||
|
|
const elapsedSeconds=Math.max(.001,(perfNow()-perfResetAt)/1000),rates={};for(const[name,value]of Object.entries(perfCounters))rates[`${name}PerSecond`]=value/elapsedSeconds;
|
|||
|
|
return{timings,counters:{...perfCounters},rates,gauges:{...perfGauges,interactionSchedulerVariant:INTERACTION_SCHEDULER_VARIANT,renderedBoards:rendered.size,staticBoards:staticRendered.size,domNodes:document.getElementsByTagName('*').length},elapsedSeconds,capturedAt:new Date().toISOString()};
|
|||
|
|
}
|
|||
|
|
function resetPerf(){perfSamples.clear();for(const key of Object.keys(perfCounters))delete perfCounters[key];for(const key of Object.keys(perfGauges))delete perfGauges[key];interactionIntervals.splice(0);interactionActiveStartedAt=globalThis.document?.body?.classList?.contains?.('is-interacting')?perfNow():0;interactionLastFrame=0;interactionBestFrameGap=Infinity;interactionFrameOpportunities=0;interactionDroppedFrames=0;perfResetAt=perfNow()}
|
|||
|
|
globalThis.BEND_PERF=Object.freeze({snapshot:perfSnapshot,reset:resetPerf});
|
|||
|
|
function observeSchedulerBattery(){
|
|||
|
|
if(typeof navigator.getBattery!=='function')return;
|
|||
|
|
void navigator.getBattery().then(battery=>{
|
|||
|
|
const sample=()=>{perfGauge('batteryLevel',battery.level);perfGauge('batteryCharging',battery.charging?1:0);if(Number.isFinite(battery.dischargingTime))perfGauge('batteryDischargingTime',battery.dischargingTime)};
|
|||
|
|
sample();battery.addEventListener?.('levelchange',sample);battery.addEventListener?.('chargingchange',sample);battery.addEventListener?.('dischargingtimechange',sample);
|
|||
|
|
}).catch(()=>{});
|
|||
|
|
}
|
|||
|
|
observeSchedulerBattery();
|
|||
|
|
function longTaskOverlappedInteraction(entry){
|
|||
|
|
const start=entry.startTime,end=start+entry.duration;if(interactionActiveStartedAt&&end>=interactionActiveStartedAt)return true;
|
|||
|
|
return interactionIntervals.some(([from,to])=>end>=from&&start<=to);
|
|||
|
|
}
|
|||
|
|
if(typeof PerformanceObserver!=='undefined')try{const observer=new PerformanceObserver(list=>{for(const entry of list.getEntries())if(entry.duration>=50){perfCount('longTasks');if(longTaskOverlappedInteraction(entry)){perfCount('interactionLongTasks');perfGauge('lastInteractionLongTaskMs',entry.duration)}perfGauge('lastLongTaskMs',entry.duration)}});observer.observe({type:'longtask',buffered:true})}catch(_){}
|
|||
|
|
if(typeof PerformanceObserver!=='undefined'&&PerformanceObserver.supportedEntryTypes?.includes?.('long-animation-frame'))try{
|
|||
|
|
const observer=new PerformanceObserver(list=>{for(const entry of list.getEntries())if(longTaskOverlappedInteraction(entry)){const scripts=[...(entry.scripts||[])].sort((a,b)=>(b.duration||0)-(a.duration||0)).slice(0,5);perfGauge('lastInteractionLoafScripts',scripts.map(script=>`${script.sourceFunctionName||script.invoker||'anonymous'}@${script.sourceURL?.split('/').pop()||'?'}:${script.sourceCharPosition||0}:${(script.duration||0).toFixed(1)}`).join(','));perfGauge('lastInteractionLoafMs',entry.duration)}});
|
|||
|
|
observer.observe({type:'long-animation-frame',buffered:true});
|
|||
|
|
}catch(_){}
|
|||
|
|
let fpsWindowStarted=perfNow(),fpsFrameCount=0,fpsLastBucket=-1,fpsLastValue=0,fpsLastFrameAt=0;
|
|||
|
|
function markVisualFrame(timestamp=perfNow()){const bucket=Math.round(timestamp*10);if(bucket===fpsLastBucket)return;fpsLastBucket=bucket;fpsFrameCount++;fpsLastFrameAt=timestamp}
|
|||
|
|
function refreshFpsCounter(){
|
|||
|
|
if(!fpsCounter)return;const now=perfNow(),elapsed=now-fpsWindowStarted;if(elapsed<450)return;
|
|||
|
|
const interacting=globalThis.document?.body?.classList?.contains?.('is-interacting')===true,idle=!interacting&&fpsFrameCount<=2&&now-fpsLastFrameAt>180;
|
|||
|
|
if(idle){fpsCounter.textContent='FPS 待機';fpsCounter.dataset.fps='idle'}
|
|||
|
|
else{fpsLastValue=Math.max(0,Math.round(fpsFrameCount*1000/elapsed));fpsCounter.textContent=`FPS ${fpsLastValue}`;fpsCounter.dataset.fps=fpsLastValue>=55?'good':fpsLastValue>=40?'ok':'low'}
|
|||
|
|
fpsFrameCount=0;fpsWindowStarted=now;
|
|||
|
|
}
|
|||
|
|
setInterval(refreshFpsCounter,500);
|
|||
|
|
const reducedMotionQuery=globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')||null;
|
|||
|
|
let noiseTimer=0,noiseTick=0,noisePainted=false;
|
|||
|
|
function noiseHash(value){value=Math.imul(value^(value>>>16),0x7feb352d);value=Math.imul(value^(value>>>15),0x846ca68b);return(value^(value>>>16))>>>0}
|
|||
|
|
function paintNoiseBackground(staticFrame=false){
|
|||
|
|
if(!noiseCanvas||document.visibilityState==='hidden')return false;
|
|||
|
|
const width=80,height=64;if(noiseCanvas.width!==width)noiseCanvas.width=width;if(noiseCanvas.height!==height)noiseCanvas.height=height;
|
|||
|
|
const context=noiseCanvas.getContext('2d',{alpha:false}),image=context.createImageData(width,height),pixels=image.data,
|
|||
|
|
phase=staticFrame?0:noiseTick++,seed=(data?.metas?.B0?.seed??STARTER_SEED)>>>0;
|
|||
|
|
for(let y=0;y<height;y++)for(let x=0;x<width;x++){
|
|||
|
|
const index=(y*width+x)*4,grain=noiseHash(seed^Math.imul(x+1,0x9e3779b1)^Math.imul(y+1,0x85ebca6b)^Math.imul(phase+1,0xc2b2ae35)),
|
|||
|
|
slow=noiseHash(seed^Math.imul((x>>2)+1,0x27d4eb2d)^Math.imul((y>>2)+1,0x165667b1)^Math.imul((phase>>1)+1,0x9e3779b9)),
|
|||
|
|
value=((grain>>>25)+(slow>>>26))*.72,glow=((grain>>>29)===0?10:0);
|
|||
|
|
pixels[index]=7+Math.min(18,value*.35);pixels[index+1]=12+Math.min(24,value*.52)+glow*.35;pixels[index+2]=17+Math.min(32,value*.72)+glow;pixels[index+3]=255;
|
|||
|
|
}
|
|||
|
|
context.putImageData(image,0,0);noisePainted=true;perfCount('noiseFrames');return true;
|
|||
|
|
}
|
|||
|
|
function pauseNoiseBackground(){if(noiseTimer){clearTimeout(noiseTimer);noiseTimer=0}}
|
|||
|
|
function scheduleNoiseBackground(force=false){
|
|||
|
|
pauseNoiseBackground();if(document.visibilityState==='hidden')return;if(force||!noisePainted)paintNoiseBackground(true);
|
|||
|
|
}
|
|||
|
|
reducedMotionQuery?.addEventListener?.('change',()=>{noisePainted=false;scheduleNoiseBackground(true)});
|
|||
|
|
const interactionCommitAt=Object.create(null),interactionInputAt=Object.create(null);
|
|||
|
|
const interactionIntervals=[];
|
|||
|
|
let interactionActiveStartedAt=0,interactionLastFrame=0,interactionFrames=0,interactionSlowFrames=0,interactionBestFrameGap=Infinity,interactionFrameOpportunities=0,interactionDroppedFrames=0,interactionQualityDowngradePending=false,wheelInteractionTimer=0,interactionSettleFrame=0;
|
|||
|
|
function recordInteractionCommit(kind,timestamp=perfNow(),inputAt=timestamp,sampleInputAge=true){
|
|||
|
|
const previous=interactionCommitAt[kind]||0,previousInput=interactionInputAt[kind]||0,gap=previous?timestamp-previous:0,inputGap=previousInput&&Number.isFinite(inputAt)?inputAt-previousInput:0;
|
|||
|
|
if(gap>0&&gap<=120&&inputGap<=25)perfObserve(`${kind}FrameGap`,gap);interactionCommitAt[kind]=timestamp;interactionInputAt[kind]=inputAt;
|
|||
|
|
if(sampleInputAge&&Number.isFinite(inputAt)&&inputAt>0&&inputAt<=timestamp+1000)perfObserve(`${kind}InputAge`,Math.max(0,timestamp-inputAt));
|
|||
|
|
}
|
|||
|
|
function refreshInteractionState(force=null){
|
|||
|
|
const drawingBoard=[...rendered.values()].find(board=>board.drawing?.pointerId!=null)||null,pendingClaimBoard=[...rendered.values()].find(board=>board.pendingClaimPointer)||null,
|
|||
|
|
active=force==null?Boolean(pan||pinch||drawingBoard||pendingClaimBoard):Boolean(force);
|
|||
|
|
const wasActive=document.body.classList.contains('is-interacting');
|
|||
|
|
document.body.classList.toggle('is-interacting',active);
|
|||
|
|
document.body.classList.toggle('is-drawing',Boolean(drawingBoard));
|
|||
|
|
document.body.classList.toggle('is-claim-pending',Boolean(pendingClaimBoard));
|
|||
|
|
topbar?.classList.toggle('drawing-active',Boolean(drawingBoard));world?.classList.toggle('camera-interacting',Boolean(pan||pinch));noiseCanvas?.classList.toggle('interaction-muted',active);
|
|||
|
|
const styledDrawingBoard=drawingBoard&&!usesLightweightDragOverlay(drawingBoard)?drawingBoard:null;
|
|||
|
|
for(const board of rendered.values())board.card.classList.toggle('input-active',board===styledDrawingBoard);
|
|||
|
|
if(active&&!wasActive)interactionActiveStartedAt=perfNow();
|
|||
|
|
else if(!active&&wasActive&&interactionActiveStartedAt){interactionIntervals.push([interactionActiveStartedAt,perfNow()]);if(interactionIntervals.length>64)interactionIntervals.splice(0,interactionIntervals.length-64);interactionActiveStartedAt=0}
|
|||
|
|
if(!active){
|
|||
|
|
interactionLastFrame=0;interactionFrames=0;interactionSlowFrames=0;interactionBestFrameGap=Infinity;interactionFrameOpportunities=0;interactionDroppedFrames=0;
|
|||
|
|
interactionCommitAt.camera=0;interactionCommitAt.pickupLogical=0;interactionCommitAt.pickupVisual=0;interactionCommitAt.pickupPreview=0;
|
|||
|
|
interactionInputAt.camera=0;interactionInputAt.pickupLogical=0;interactionInputAt.pickupVisual=0;interactionInputAt.pickupPreview=0;
|
|||
|
|
if(interactionQualityDowngradePending&&!autoReducedEffects){interactionQualityDowngradePending=false;autoReducedEffects=true;applyUiSettings()}
|
|||
|
|
if(zoomDetailsDirty)updateZoomPresentation(true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
function scheduleInteractionSettlePresentation(){
|
|||
|
|
if(interactionSettleFrame)return;
|
|||
|
|
interactionSettleFrame=requestAnimationFrame(()=>{
|
|||
|
|
interactionSettleFrame=0;
|
|||
|
|
if(document.body.classList.contains('is-interacting')){scheduleInteractionSettlePresentation();return}
|
|||
|
|
setPickupScenePresentation(null,false);
|
|||
|
|
updateZoomPresentation(true);scheduleLodPass();scheduleMinimap();scheduleWorldOverview();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
function observeInteractionFrame(timestamp=perfNow(),workDuration=0){
|
|||
|
|
if(interactionLastFrame){
|
|||
|
|
const gap=Math.max(0,timestamp-interactionLastFrame);perfObserve('interactionFrameGap',gap);
|
|||
|
|
if(gap>=4&&gap<=50)interactionBestFrameGap=Math.min(interactionBestFrameGap,gap);
|
|||
|
|
const interval=Number.isFinite(interactionBestFrameGap)?interactionBestFrameGap:16.67,opportunities=Math.max(1,Math.round(gap/interval));
|
|||
|
|
interactionFrameOpportunities+=opportunities;interactionDroppedFrames+=Math.max(0,opportunities-1);perfGauge('interactionDroppedFrameRatio',interactionDroppedFrames/Math.max(1,interactionFrameOpportunities));
|
|||
|
|
}
|
|||
|
|
const observedInterval=Number.isFinite(interactionBestFrameGap)?interactionBestFrameGap:16.67,slowWorkThreshold=Math.max(6,observedInterval*.7);
|
|||
|
|
interactionFrames++;if(workDuration>slowWorkThreshold)interactionSlowFrames++;
|
|||
|
|
if(interactionFrames>=12){perfGauge('interactionSlowWorkRatio',interactionSlowFrames/interactionFrames);if(interactionSlowFrames>=6&&!autoReducedEffects)interactionQualityDowngradePending=true;interactionFrames=0;interactionSlowFrames=0}
|
|||
|
|
interactionLastFrame=timestamp;
|
|||
|
|
}
|
|||
|
|
function pulseWheelInteraction(){
|
|||
|
|
refreshInteractionState(true);observeInteractionFrame();clearTimeout(wheelInteractionTimer);wheelInteractionTimer=setTimeout(()=>{refreshInteractionState();recordCameraAnchor();ensureBoards();repositionActiveBoardHud();scheduleMinimap(true);redrawOnlineLayersAfterCamera()},140);
|
|||
|
|
}
|
|||
|
|
function invalidateLineGraphCaches(boardId=null,pathIndex=null){
|
|||
|
|
lineGraphRevision++;
|
|||
|
|
if(!boardId){lineGraphCacheRevision=lineGraphRevision;lineComponentCache=new Map();lineWidthCache=new Map();return}
|
|||
|
|
const prefix=`${boardId}:`,components=new Set();
|
|||
|
|
if(Number.isInteger(pathIndex)){const component=lineComponentCache.get(linePathKey(boardId,pathIndex));if(component)components.add(component)}
|
|||
|
|
else for(const[key,component]of lineComponentCache)if(key.startsWith(prefix))components.add(component);
|
|||
|
|
const meta=data?.metas?.[boardId],paths=data?.states?.[boardId]?.paths||[],indexes=Number.isInteger(pathIndex)?[pathIndex]:paths.map((_,index)=>index);
|
|||
|
|
if(meta?.puzzle)for(const index of indexes){const path=paths[index];if(!path)continue;for(const gateIndex of[...(path.detachedStart?[]:[path.startGate]),path.endGate]){if(gateIndex==null)continue;const hit=matchingNeighborGate(meta,gateIndex);if(!hit)continue;const neighborPaths=data.states?.[hit.meta.id]?.paths||[],neighborIndex=neighborPaths.findIndex(candidate=>pathUsesGate(candidate,hit.gateIndex)),component=neighborIndex>=0?lineComponentCache.get(linePathKey(hit.meta.id,neighborIndex)):null;if(component)components.add(component)}}
|
|||
|
|
for(const component of components)for(const[meta,index]of component.members||[]){const key=linePathKey(meta.id,index);if(lineComponentCache.get(key)===component)lineComponentCache.delete(key);lineWidthCache.delete(key)}
|
|||
|
|
if(Number.isInteger(pathIndex)){lineComponentCache.delete(linePathKey(boardId,pathIndex));lineWidthCache.delete(linePathKey(boardId,pathIndex))}
|
|||
|
|
else for(const key of[...lineWidthCache.keys()])if(key.startsWith(prefix))lineWidthCache.delete(key);
|
|||
|
|
lineGraphCacheRevision=lineGraphRevision;
|
|||
|
|
}
|
|||
|
|
function currentLineGraphCaches(){
|
|||
|
|
if(lineGraphCacheRevision!==lineGraphRevision){lineGraphCacheRevision=lineGraphRevision;lineComponentCache=new Map();lineWidthCache=new Map()}
|
|||
|
|
return{components:lineComponentCache,widths:lineWidthCache,geometries:lineMinimapGeometryCache};
|
|||
|
|
}
|
|||
|
|
function invalidateMinimapWorld(){minimapWorldRevision++;minimapDirty=true;minimapCache=null;scheduleMinimap()}
|
|||
|
|
function invalidateWorldPresentation(){invalidateMinimapWorld();overviewDirty=true;overviewCache=null}
|
|||
|
|
function svgEl(tag,attrs={}){const e=document.createElementNS('http://www.w3.org/2000/svg',tag);for(const[k,v]of Object.entries(attrs))e.setAttribute(k,v);return e}
|
|||
|
|
let soundContext=null,lastStretchSoundAt=0;
|
|||
|
|
function soundTone(frequency,duration=.08,{gain=.035,type='sine',slide=1,delay=0}={}){
|
|||
|
|
if(!uiSettings.soundEnabled)return;
|
|||
|
|
try{
|
|||
|
|
const Audio=window.AudioContext||window.webkitAudioContext;if(!Audio)return;
|
|||
|
|
soundContext=soundContext&&soundContext.state!=='closed'?soundContext:new Audio();
|
|||
|
|
if(soundContext.state==='suspended')void soundContext.resume();
|
|||
|
|
const now=soundContext.currentTime+delay,osc=soundContext.createOscillator(),amp=soundContext.createGain();
|
|||
|
|
osc.type=type;osc.frequency.setValueAtTime(frequency,now);osc.frequency.exponentialRampToValueAtTime(Math.max(40,frequency*slide),now+duration);
|
|||
|
|
const outputGain=Math.min(.28,Math.max(.001,gain*SOUND_GAIN_MULTIPLIER));
|
|||
|
|
amp.gain.setValueAtTime(.0001,now);amp.gain.exponentialRampToValueAtTime(outputGain,now+.008);amp.gain.exponentialRampToValueAtTime(.0001,now+duration);
|
|||
|
|
osc.connect(amp).connect(soundContext.destination);osc.start(now);osc.stop(now+duration+.02);
|
|||
|
|
}catch(_){}
|
|||
|
|
}
|
|||
|
|
function playSound(kind){
|
|||
|
|
if(kind==='grab'){soundTone(190,.055,{gain:.028,type:'triangle',slide:1.18});soundTone(280,.045,{gain:.016,type:'sine',slide:.92,delay:.018})}
|
|||
|
|
else if(kind==='stretch'){const now=performance.now();if(now-lastStretchSoundAt<48)return;lastStretchSoundAt=now;soundTone(145,.045,{gain:.014,type:'triangle',slide:1.08})}
|
|||
|
|
else if(kind==='gate'){soundTone(210,.09,{gain:.035,type:'triangle',slide:1.7});soundTone(430,.08,{gain:.02,type:'sine',slide:1.22,delay:.045})}
|
|||
|
|
else if(kind==='clear'){soundTone(260,.15,{gain:.04,type:'triangle',slide:1.65});soundTone(520,.18,{gain:.028,type:'sine',slide:1.45,delay:.08})}
|
|||
|
|
else if(kind==='buy'){soundTone(330,.07,{gain:.03,type:'square',slide:1.22});soundTone(495,.09,{gain:.026,type:'triangle',slide:1.28,delay:.055})}
|
|||
|
|
else if(kind==='reset'){soundTone(220,.09,{gain:.025,type:'triangle',slide:.58});soundTone(130,.11,{gain:.02,type:'sine',slide:.75,delay:.045})}
|
|||
|
|
else soundTone(245,.045,{gain:.018,type:'triangle',slide:1.06});
|
|||
|
|
}
|
|||
|
|
function toast(message,duration=2200){const e=document.querySelector('#toast');e.textContent=message;e.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>e.classList.remove('show'),duration)}
|
|||
|
|
function nextRevision(){lastRevision=Math.max(lastRevision+1,Date.now()*1000);return lastRevision}
|
|||
|
|
function seedRevisionClock(snapshot=data){
|
|||
|
|
let maximum=Math.max(lastRevision,snapshot?.globalRev||0,(snapshot?.clockFloor||0)*1000);
|
|||
|
|
for(const meta of Object.values(snapshot?.metas||{}))maximum=Math.max(maximum,meta?.rev||0);
|
|||
|
|
for(const state of Object.values(snapshot?.states||{}))maximum=Math.max(maximum,state?.rev||0);
|
|||
|
|
for(const tombstone of recoveredDeletionTombstones.values())maximum=Math.max(maximum,tombstone?.rev||0);
|
|||
|
|
lastRevision=maximum;return maximum;
|
|||
|
|
}
|
|||
|
|
function showStatus(message,{retry=true,fresh=false}={}){
|
|||
|
|
statusMessage.textContent=message;statusPanel.hidden=false;
|
|||
|
|
document.querySelector('#retryBtn').hidden=!retry;
|
|||
|
|
document.querySelector('#freshBtn').hidden=!fresh;
|
|||
|
|
}
|
|||
|
|
function hideStatus(){statusPanel.hidden=true}
|
|||
|
|
const {hash32,rngFrom,shuffle,macroDifficulty,difficultyFitsRegion,SHAPES,solverDifficulty,H_PORT_PROFILES,V_PORT_PROFILES,horizontalBoundaryKey,verticalBoundaryKey}=BendPuzzle;
|
|||
|
|
let workerSeq=0;
|
|||
|
|
const workerJobs=new Map();
|
|||
|
|
let puzzleWorker=null,workerRestartTimer=null,workerFailureCount=0,workerDisabledUntil=0,workerRetryAt=0;
|
|||
|
|
function fallbackWorkerJob(id,error){
|
|||
|
|
const job=workerJobs.get(id);if(!job)return;
|
|||
|
|
workerJobs.delete(id);clearTimeout(job.timeout);
|
|||
|
|
job.reject(error);
|
|||
|
|
}
|
|||
|
|
function failWorkerJobs(error){for(const id of[...workerJobs.keys()])fallbackWorkerJob(id,error)}
|
|||
|
|
function scheduleWorkerRestart(){
|
|||
|
|
clearTimeout(workerRestartTimer);
|
|||
|
|
const delay=Math.min(30000,1000*2**Math.min(5,workerFailureCount++));
|
|||
|
|
workerRetryAt=Date.now()+delay;workerRestartTimer=setTimeout(()=>{workerRestartTimer=null;workerRetryAt=0;createPuzzleWorker()},delay);
|
|||
|
|
}
|
|||
|
|
function createPuzzleWorker(){
|
|||
|
|
if(puzzleWorker){clearTimeout(workerRestartTimer);workerRestartTimer=null;workerRetryAt=0;return puzzleWorker}
|
|||
|
|
if(workerRestartTimer||Date.now()<workerRetryAt)return null;
|
|||
|
|
if(Date.now()<workerDisabledUntil){puzzleWorker=null;return null}
|
|||
|
|
if(typeof Worker==='undefined'){puzzleWorker=null;workerRetryAt=Date.now()+30000;return null}
|
|||
|
|
clearTimeout(workerRestartTimer);workerRestartTimer=null;
|
|||
|
|
try{
|
|||
|
|
const worker=new Worker(`puzzle-worker.js?v=${APP_VERSION}-${GENERATOR_VERSION}`);
|
|||
|
|
worker.onmessage=({data:message})=>{
|
|||
|
|
if(worker!==puzzleWorker)return;
|
|||
|
|
const job=workerJobs.get(message.id);if(!job)return;
|
|||
|
|
if(message.generatorVersion!==GENERATOR_VERSION){const error=new Error('\u76e4\u9762\u751f\u6210\u6a5f\u80fd\u306e\u30d0\u30fc\u30b8\u30e7\u30f3\u304c\u4e00\u81f4\u3057\u307e\u305b\u3093\u3002');fallbackWorkerJob(message.id,error);restartPuzzleWorker(error);return}
|
|||
|
|
workerJobs.delete(message.id);clearTimeout(job.timeout);workerFailureCount=0;
|
|||
|
|
const deliver=()=>message.error?job.reject(new Error(message.error)):job.resolve(message.result??message.puzzle);
|
|||
|
|
if(globalThis.document?.body?.classList?.contains?.('is-interacting')){perfCount('workerResultsDeferredDuringInteraction');waitForInteractionSettle().then(deliver)}
|
|||
|
|
else deliver();
|
|||
|
|
};
|
|||
|
|
const failed=event=>{if(worker===puzzleWorker)restartPuzzleWorker(new Error(event?.message||'\u76e4\u9762\u751f\u6210\u51e6\u7406\u304c\u505c\u6b62\u3057\u307e\u3057\u305f\u3002'))};
|
|||
|
|
worker.onerror=failed;worker.onmessageerror=failed;
|
|||
|
|
puzzleWorker=worker;return worker;
|
|||
|
|
}catch(error){puzzleWorker=null;console.warn('BEND FIELD: worker unavailable',error);scheduleWorkerRestart();return null}
|
|||
|
|
}
|
|||
|
|
function restartPuzzleWorker(error,{cooldown=false}={}){
|
|||
|
|
if(error&&!error.code)error.code='WORKER_UNAVAILABLE';
|
|||
|
|
const old=puzzleWorker;puzzleWorker=null;if(old)old.terminate();
|
|||
|
|
if(cooldown)workerDisabledUntil=Date.now()+5000;
|
|||
|
|
failWorkerJobs(error);
|
|||
|
|
if(cooldown){clearTimeout(workerRestartTimer);workerRetryAt=Date.now()+5000;workerRestartTimer=setTimeout(()=>{workerRestartTimer=null;workerRetryAt=0;workerDisabledUntil=0;createPuzzleWorker()},5000)}else scheduleWorkerRestart();
|
|||
|
|
}
|
|||
|
|
function generatePuzzleAsync(chunks,seed,level,originX=0,originY=0,timeoutMs=1500,generationOptions=null){
|
|||
|
|
const args={chunks,seed,level,originX,originY,generationOptions};
|
|||
|
|
if(!puzzleWorker)createPuzzleWorker();
|
|||
|
|
if(!puzzleWorker){const error=new Error('\u76e4\u9762\u751f\u6210\u6a5f\u80fd\u3092\u5229\u7528\u3067\u304d\u307e\u305b\u3093\u3002');error.code='WORKER_UNAVAILABLE';perfCount('workerUnavailable');showStatus('\u76e4\u9762\u751f\u6210\u30ef\u30fc\u30ab\u30fc\u3092\u8d77\u52d5\u3067\u304d\u307e\u305b\u3093\u3002\u518d\u8a66\u884c\u3057\u3066\u304f\u3060\u3055\u3044\u3002',{retry:true,fresh:false});return Promise.reject(error)}
|
|||
|
|
return new Promise((resolve,reject)=>{
|
|||
|
|
const id=++workerSeq,job={resolve,reject,args,timeout:null};
|
|||
|
|
job.timeout=setTimeout(()=>{
|
|||
|
|
const error=new Error('\u76e4\u9762\u306e\u751f\u6210\u304c\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3057\u305f\u3002');
|
|||
|
|
fallbackWorkerJob(id,error);restartPuzzleWorker(error,{cooldown:true});
|
|||
|
|
},Math.max(500,timeoutMs));
|
|||
|
|
workerJobs.set(id,job);
|
|||
|
|
try{puzzleWorker.postMessage({id,...args,generatorVersion:GENERATOR_VERSION})}
|
|||
|
|
catch(error){fallbackWorkerJob(id,error);restartPuzzleWorker(error)}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
function verifyPuzzleUniquenessAsync(puzzle,timeoutMs=650){
|
|||
|
|
const worker=createPuzzleWorker();if(!worker){const error=new Error('\u4e00\u610f\u89e3\u691c\u8a3c\u30ef\u30fc\u30ab\u30fc\u3092\u5229\u7528\u3067\u304d\u307e\u305b\u3093\u3002');error.code='WORKER_UNAVAILABLE';return Promise.reject(error)}
|
|||
|
|
return new Promise((resolve,reject)=>{
|
|||
|
|
const id=++workerSeq,job={resolve,reject,args:null,timeout:null,kind:'verify'};
|
|||
|
|
job.timeout=setTimeout(()=>{const current=workerJobs.get(id);if(!current)return;workerJobs.delete(id);const result={status:'timeout',solutions:0,nodes:0,elapsedMs:timeoutMs};resolve(result)},Math.max(500,timeoutMs));
|
|||
|
|
workerJobs.set(id,job);
|
|||
|
|
try{worker.postMessage({id,action:'verify',puzzle,limits:{maxMs:450,nodeCap:250000},generatorVersion:GENERATOR_VERSION})}
|
|||
|
|
catch(error){fallbackWorkerJob(id,error);restartPuzzleWorker(error)}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
function refreshClosedVoidAroundMeta(meta){
|
|||
|
|
for(const[dx,dy]of meta?.chunks||[]){const unitX=meta.x+dx,unitY=meta.y+dy;for(const[dr,dc]of[[0,0],...Object.values(SIDE_D)]){const x=unitX+dc,y=unitY+dr,key=key2(x,y);if(isClosedVoidUnit(x,y))closedVoidKeys.add(key);else closedVoidKeys.delete(key)}}
|
|||
|
|
}
|
|||
|
|
function addMetaToOccupancy(meta){
|
|||
|
|
if(!meta||!validChunkShape(meta.chunks))return false;
|
|||
|
|
for(const[dx,dy]of meta.chunks)occupancy.set(key2(meta.x+dx,meta.y+dy),meta.id);
|
|||
|
|
refreshClosedVoidAroundMeta(meta);
|
|||
|
|
worldGeometryRevision++;fieldBoundsCache=null;adjacencyCache.clear();invalidateLineGraphCaches();invalidateWorldPresentation();return true;
|
|||
|
|
}
|
|||
|
|
function rebuildOccupancy(){
|
|||
|
|
occupancy=new Map();closedVoidKeys=new Set();worldGeometryRevision++;fieldBoundsCache=null;
|
|||
|
|
for(const meta of Object.values(data.metas))if(meta&&validChunkShape(meta.chunks))for(const[dx,dy]of meta.chunks)occupancy.set(key2(meta.x+dx,meta.y+dy),meta.id);
|
|||
|
|
const candidates=new Set();for(const key of occupancy.keys()){const[unitX,unitY]=key.split(',').map(Number);for(const[dr,dc]of Object.values(SIDE_D))candidates.add(key2(unitX+dc,unitY+dr))}
|
|||
|
|
for(const key of candidates){const[unitX,unitY]=key.split(',').map(Number);if(isClosedVoidUnit(unitX,unitY))closedVoidKeys.add(key)}
|
|||
|
|
adjacencyCache.clear();invalidateLineGraphCaches();invalidateWorldPresentation();
|
|||
|
|
}
|
|||
|
|
function invalidateEconomyCaches(){inventoryCache=null;spentScoreCache=null;statsDirty=true}
|
|||
|
|
function stateStatSignature(state){return`${state?.solved===true?1:0}:${Math.max(0,Number(state?.scoreAwarded)||0)}`}
|
|||
|
|
function stateEconomySignature(state){return JSON.stringify((state?.store?.purchases||[]).map(purchase=>[purchase.id,purchase.paidCost||0,purchase.boughtAt||0]))}
|
|||
|
|
function economySignatureSpend(signature){
|
|||
|
|
let rows=[];try{rows=JSON.parse(signature||'[]')}catch(_){}
|
|||
|
|
return rows.reduce((sum,row)=>{const item=storeItem(row?.[0]);return sum+(item?row[1]||item.cost:0)},0);
|
|||
|
|
}
|
|||
|
|
function rememberStateSignatures(id,state){
|
|||
|
|
const previousStat=stateStatSignatures.get(id),previousEconomy=stateEconomySignatures.get(id),stat=stateStatSignature(state),economy=stateEconomySignature(state),statChanged=previousStat!==stat,economyChanged=previousEconomy!==economy;
|
|||
|
|
stateStatSignatures.set(id,stat);stateEconomySignatures.set(id,economy);
|
|||
|
|
if(statChanged&&!statsDirty){
|
|||
|
|
const[oldSolved,oldAward]=(previousStat||'0:0').split(':').map(Number),[newSolved,newAward]=stat.split(':').map(Number);
|
|||
|
|
const awardDelta=newAward-oldAward,earned=Number.isFinite(cachedStats.earned)?Math.max(0,cachedStats.earned+awardDelta):null,
|
|||
|
|
score=earned==null?Math.max(0,cachedStats.score+awardDelta):Math.max(0,earned-(spentScoreCache||0));
|
|||
|
|
cachedStats={solved:Math.max(0,cachedStats.solved+newSolved-oldSolved),earned,score};
|
|||
|
|
}
|
|||
|
|
if(economyChanged&&previousEconomy!=null){
|
|||
|
|
const spentDelta=economySignatureSpend(economy)-economySignatureSpend(previousEconomy);
|
|||
|
|
if(spentScoreCache!=null)spentScoreCache=Math.max(0,spentScoreCache+spentDelta);
|
|||
|
|
if(!statsDirty)cachedStats.score=Math.max(0,cachedStats.score-spentDelta);
|
|||
|
|
if(inventoryCache){
|
|||
|
|
inventoryCache=inventoryCache.filter(entry=>entry.meta.id!==id);const meta=data.metas[id],store=state?.store;
|
|||
|
|
if(meta&&store)for(const purchase of store.purchases||[])inventoryCache.push({meta,st:state,store,purchase});
|
|||
|
|
inventoryCache.sort((a,b)=>(a.purchase.boughtAt||0)-(b.purchase.boughtAt||0));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
function currentCloudPending(){
|
|||
|
|
return{metaIds:[...cloudJournalMetaIds],stateIds:[...cloudJournalStateIds].filter(id=>data?.states?.[id]?.solved===true),deleted:[...cloudJournalDeletedIds],globalChanged:cloudJournalGlobalChanged};
|
|||
|
|
}
|
|||
|
|
function restoreCloudPending(pending=data.cloudPending){
|
|||
|
|
const clean=normalizeCloudPending(pending);cloudJournalMetaIds.clear();cloudJournalStateIds.clear();cloudJournalDeletedIds.clear();
|
|||
|
|
for(const id of clean.metaIds)cloudJournalMetaIds.add(id);for(const id of clean.stateIds){if(data?.states?.[id]?.solved===true)cloudJournalStateIds.add(id);else cloudOutboxDeleteKeys.add(`state:${id}`)}for(const id of clean.deleted)cloudJournalDeletedIds.add(id);
|
|||
|
|
cloudJournalGlobalChanged=clean.globalChanged;cloudJournalChangeSeq=0;data.cloudPending=currentCloudPending();
|
|||
|
|
}
|
|||
|
|
function noteCloudRow(kind,id){
|
|||
|
|
if(!id||!cloudApiEnabled)return;
|
|||
|
|
if(kind==='state'&&data?.states?.[id]?.solved!==true){cloudJournalStateIds.delete(id);cloudOutboxDeleteKeys.add(`state:${id}`);return}
|
|||
|
|
if(kind==='deleted'){cloudJournalMetaIds.delete(id);cloudJournalStateIds.delete(id);cloudJournalDeletedIds.add(id);cloudOutboxDeleteKeys.add(`meta:${id}`);cloudOutboxDeleteKeys.add(`state:${id}`)}
|
|||
|
|
else{cloudJournalDeletedIds.delete(id);cloudOutboxDeleteKeys.add(`deleted:${id}`);(kind==='meta'?cloudJournalMetaIds:cloudJournalStateIds).add(id)}
|
|||
|
|
cloudJournalChangeSeq++;
|
|||
|
|
}
|
|||
|
|
function markGlobalDirty(cloudRelevant=true){globalDirty=true;globalChangeSeq++;if(data){data.globalRev=nextRevision();data.globalRevAuthor=sessionId}if(cloudRelevant&&cloudApiEnabled){cloudJournalGlobalChanged=true;cloudJournalChangeSeq++}}
|
|||
|
|
function hasPendingPersistence(){return globalDirty||dirtyMetaIds.size>0||dirtyStateIds.size>0||deletedBoardIds.size>0||cloudOutboxDeleteKeys.size>0}
|
|||
|
|
function markMetaDirty(id){if(id){dirtyMetaIds.add(id);worldGeometryRevision++;fieldBoundsCache=null;if(data.metas[id]){if(!cloudApplyingRemote)data.metas[id].revAuthor=sessionId;deletedBoardIds.delete(id);deletedBoardRevisions.delete(id);deletedBoardAuthors.delete(id)}if(!cloudApplyingRemote)noteCloudRow('meta',id)}markGlobalDirty(!cloudApplyingRemote);invalidateWorldPresentation()}
|
|||
|
|
function markStateDirty(id,pathIndex=null){if(id){dirtyStateIds.add(id);if(data.states[id]&&!cloudApplyingRemote)data.states[id].revAuthor=sessionId;if(!cloudApplyingRemote)noteCloudRow('state',id);rememberStateSignatures(id,data.states[id])}markGlobalDirty(!cloudApplyingRemote);invalidateLineGraphCaches(id,pathIndex);invalidateWorldPresentation()}
|
|||
|
|
function markBoardDeleted(id){if(!id)return;dirtyMetaIds.delete(id);dirtyStateIds.delete(id);deletedBoardIds.add(id);deletedBoardRevisions.set(id,nextRevision());deletedBoardAuthors.set(id,sessionId);worldGeometryRevision++;fieldBoundsCache=null;if(!cloudApplyingRemote)noteCloudRow('deleted',id);stateStatSignatures.delete(id);stateEconomySignatures.delete(id);markGlobalDirty(!cloudApplyingRemote);statsDirty=true;invalidateEconomyCaches();invalidateLineGraphCaches(id);invalidateWorldPresentation()}
|
|||
|
|
function replaceObjectContents(target,source){
|
|||
|
|
for(const key of Object.keys(target))if(!(key in source))delete target[key];
|
|||
|
|
Object.assign(target,source);return target;
|
|||
|
|
}
|
|||
|
|
function metaState(id){
|
|||
|
|
if(!data.states[id]||typeof data.states[id]!=='object'){
|
|||
|
|
data.states[id]=normalizeState(null);normalizedStateObjects.add(data.states[id]);markStateDirty(id);return data.states[id];
|
|||
|
|
}
|
|||
|
|
const state=data.states[id];
|
|||
|
|
if(normalizedStateObjects.has(state))return state;
|
|||
|
|
replaceObjectContents(state,normalizeState(state));normalizedStateObjects.add(state);markStateDirty(id);return state;
|
|||
|
|
}
|
|||
|
|
function storeItem(id){return STORE_ITEM_CATALOG.get(id)||null}
|
|||
|
|
function normalizeStoreItemIds(raw){
|
|||
|
|
if(!Array.isArray(raw)||raw.length!==13||new Set(raw).size!==13)return null;
|
|||
|
|
const items=raw.map(storeItem);return items.every(Boolean)&&items.filter(item=>item.cursorStyle).length===12&&items.filter(item=>!item.cursorStyle).length===1?[...raw]:null;
|
|||
|
|
}
|
|||
|
|
function seededStoreItemIds(seed){
|
|||
|
|
const cursorPool=shuffle([...CURSOR_ITEMS],rngFrom(hash32((seed>>>0)^0x5f356495))),
|
|||
|
|
otherPool=shuffle(STORE_ITEMS.filter(item=>!item.cursorStyle),rngFrom(hash32((seed>>>0)^0x2c9277b5))),
|
|||
|
|
selected=[...cursorPool.slice(0,12),...otherPool.slice(0,1)];
|
|||
|
|
return shuffle(selected,rngFrom(hash32((seed>>>0)^0x6d2b79f5))).map(item=>item.id);
|
|||
|
|
}
|
|||
|
|
function storeInventoryItems(meta,store=null){
|
|||
|
|
if(!meta)return[];const ids=normalizeStoreItemIds(store?.itemIds)||seededStoreItemIds(meta.seed);
|
|||
|
|
return ids.map(storeItem).filter(Boolean);
|
|||
|
|
}
|
|||
|
|
let playerEconomyLoaded=false;
|
|||
|
|
function onlinePlayerEconomy(){return Boolean(cloudApiEnabled&&cloudAvailable&&data.cloudProfile&&playerEconomyLoaded)}
|
|||
|
|
function playerPurchaseForStore(boardId,itemId){return normalizePlayerPurchases(data.playerPurchases).find(purchase=>purchase.boardId===boardId&&purchase.itemId===itemId)||null}
|
|||
|
|
function inventoryEntries(itemId=null){
|
|||
|
|
if(!inventoryCache){
|
|||
|
|
inventoryCache=[];
|
|||
|
|
if(onlinePlayerEconomy())for(const source of normalizePlayerPurchases(data.playerPurchases)){const item=storeItem(source.itemId);if(!item)continue;const meta=data.metas[source.boardId]||null,st=meta?metaState(meta.id):null,store=st?.store||null,purchase={...source,id:source.itemId};inventoryCache.push({meta,st,store,purchase,personal:true})}
|
|||
|
|
else for(const meta of Object.values(data.metas)){const st=metaState(meta.id),store=st.store;if(!store)continue;for(const purchase of store.purchases)inventoryCache.push({meta,st,store,purchase,personal:false})}
|
|||
|
|
inventoryCache.sort((a,b)=>(a.purchase.boughtAt||0)-(b.purchase.boughtAt||0));
|
|||
|
|
}
|
|||
|
|
return itemId?inventoryCache.filter(entry=>entry.purchase.id===itemId):[...inventoryCache];
|
|||
|
|
}
|
|||
|
|
function inventoryCount(itemId=null){return inventoryEntries(itemId).length}
|
|||
|
|
function activeScoreLensCount(){return data.scoreLensEnabled===true?Math.max(debugAllItemsEnabled()?1:0,inventoryCount('score-lens')):0}
|
|||
|
|
function spentScoreTotal(){
|
|||
|
|
if(spentScoreCache!=null)return spentScoreCache;
|
|||
|
|
let spent=0;if(onlinePlayerEconomy())for(const purchase of normalizePlayerPurchases(data.playerPurchases)){const item=storeItem(purchase.itemId);if(item)spent+=purchase.paidCost||item.cost}
|
|||
|
|
else for(const id of Object.keys(data.metas)){const store=metaState(id).store;if(!store)continue;for(const purchase of store.purchases){const item=storeItem(purchase.id);if(item)spent+=purchase.paidCost||item.cost}}
|
|||
|
|
spentScoreCache=spent;return spent;
|
|||
|
|
}
|
|||
|
|
function pruneAndCount(){
|
|||
|
|
if(orphanPruneDirty){for(const id of Object.keys(data.states))if(!data.metas[id]){delete data.states[id];markBoardDeleted(id)}orphanPruneDirty=false}
|
|||
|
|
if(statsDirty){
|
|||
|
|
let solved=0,earned=onlinePlayerEconomy()?Math.max(0,Number(data.playerEarnedScore)||0):bonusEventTotal();data.bonusScore=onlinePlayerEconomy()?0:earned;
|
|||
|
|
for(const id of Object.keys(data.metas)){const st=metaState(id);if(st.solved)solved++;if(!onlinePlayerEconomy())earned+=st.scoreAwarded}
|
|||
|
|
cachedStats={solved,earned,score:Math.max(0,earned-spentScoreTotal())};statsDirty=false;
|
|||
|
|
}
|
|||
|
|
data.solved=cachedStats.solved;data.score=cachedStats.score;solvedCountEl.textContent=String(data.solved);scoreCountEl.textContent=formatScore(data.score);
|
|||
|
|
}
|
|||
|
|
function snapshotForStorage(updatedAt=trustedNow()){
|
|||
|
|
const started=perfStart();
|
|||
|
|
const metas=Object.fromEntries(metaRowsForStorage().map(meta=>[meta.id,meta]));
|
|||
|
|
const states=Object.fromEntries(stateRowsForStorage().map(row=>[row.id,row.value]));
|
|||
|
|
const snapshot={...globalForStorage(data,updatedAt),cloudPending:currentCloudPending(),metas,states};perfGauge('snapshotBoards',Object.keys(metas).length);perfEnd('snapshotForStorage',started);return snapshot;
|
|||
|
|
}
|
|||
|
|
function compactSnapshot(updatedAt=trustedNow()){
|
|||
|
|
pruneAndCount();
|
|||
|
|
return snapshotForStorage(updatedAt);
|
|||
|
|
}
|
|||
|
|
function mergePurchases(a=[],b=[]){const map=new Map();for(const purchase of[...a,...b]){const current=map.get(purchase.id);if(!current||(purchase.boughtAt||0)>(current.boughtAt||0))map.set(purchase.id,deepClone(purchase))}return[...map.values()]}
|
|||
|
|
function mergeBoardStates(current,incoming){
|
|||
|
|
if(!current)return deepClone(incoming);if(!incoming)return current;
|
|||
|
|
if(current.solved||incoming.solved){
|
|||
|
|
const selected=current.solved&&!incoming.solved?current:incoming.solved&&!current.solved?incoming:compareRevisionVersions(incoming,current)>=0?incoming:current,
|
|||
|
|
other=selected===current?incoming:current,chosen=deepClone(selected);
|
|||
|
|
if(chosen.store&&other.store)chosen.store.purchases=mergePurchases(chosen.store.purchases,other.store.purchases);
|
|||
|
|
chosen.specialProgress=mergeSpecialProgress(current.specialProgress,incoming.specialProgress);
|
|||
|
|
const version=newerRevisionValue(current,incoming);chosen.rev=version.rev||0;chosen.revAuthor=version.revAuthor||'';return chosen;
|
|||
|
|
}
|
|||
|
|
const newer=compareRevisionVersions(incoming,current)>=0?incoming:current,older=newer===incoming?current:incoming,result=deepClone(newer),usedGates=new Set(),usedCells=new Set();
|
|||
|
|
for(const path of result.paths||[]){if(!path.detachedStart)usedGates.add(path.startGate);if(path.endGate!=null)usedGates.add(path.endGate);if(path.openGate!=null)usedGates.add(path.openGate);for(const cell of path.cells)usedCells.add(ckey(...cell))}
|
|||
|
|
for(const path of older.paths||[]){
|
|||
|
|
const gates=[...(path.detachedStart?[]:[path.startGate]),...(path.endGate==null?[]:[path.endGate]),...(path.openGate==null?[]:[path.openGate])],conflict=gates.some(g=>usedGates.has(g))||path.cells.some(cell=>usedCells.has(ckey(...cell)));if(conflict)continue;
|
|||
|
|
result.paths.push(deepClone(path));for(const g of gates)usedGates.add(g);for(const cell of path.cells)usedCells.add(ckey(...cell));
|
|||
|
|
}
|
|||
|
|
result.specialProgress=mergeSpecialProgress(current.specialProgress,incoming.specialProgress);
|
|||
|
|
result.rev=newer.rev||0;result.revAuthor=newer.revAuthor||'';return result;
|
|||
|
|
}
|
|||
|
|
function sameMetaGeometry(a,b){return a&&b&&a.x===b.x&&a.y===b.y&&a.seed===b.seed&&JSON.stringify(a.chunks)===JSON.stringify(b.chunks)&&JSON.stringify(a.sealedSides||[])===JSON.stringify(b.sealedSides||[])}
|
|||
|
|
function resolveMergedOverlaps(){
|
|||
|
|
const metas=Object.values(data.metas).sort((a,b)=>a.id==='B0'?-1:b.id==='B0'?1:(b.rev||0)-(a.rev||0)||a.id.localeCompare(b.id,undefined,{numeric:true}));
|
|||
|
|
const occupied=new Set(),removed=[];
|
|||
|
|
for(const meta of metas){
|
|||
|
|
if(meta.chunks.some(([dx,dy])=>occupied.has(key2(meta.x+dx,meta.y+dy)))){removed.push(meta.id);continue}
|
|||
|
|
for(const[dx,dy]of meta.chunks)occupied.add(key2(meta.x+dx,meta.y+dy));
|
|||
|
|
}
|
|||
|
|
for(const id of removed){delete data.metas[id];delete data.states[id];markBoardDeleted(id);destroyBoard(rendered.get(id))}
|
|||
|
|
return removed;
|
|||
|
|
}
|
|||
|
|
function clearSharedWorldJournalRow(kind,id){
|
|||
|
|
if(kind==='meta'){cloudJournalMetaIds.delete(id);cloudOutboxDeleteKeys.add(`meta:${id}`)}
|
|||
|
|
else if(kind==='state'){cloudJournalStateIds.delete(id);cloudOutboxDeleteKeys.add(`state:${id}`)}
|
|||
|
|
cloudJournalDeletedIds.delete(id);cloudOutboxDeleteKeys.add(`deleted:${id}`);
|
|||
|
|
}
|
|||
|
|
function applyAuthoritativeSharedGlobal(external){
|
|||
|
|
if(!external)return;
|
|||
|
|
if(Number.isSafeInteger(external.nextId)&&external.nextId>0)data.nextId=external.nextId;
|
|||
|
|
if(Number.isSafeInteger(external.solved)&&external.solved>=0)data.solved=external.solved;
|
|||
|
|
if(Number.isFinite(external.lastSolveAt)&&external.lastSolveAt>=0)data.lastSolveAt=external.lastSolveAt;
|
|||
|
|
if(Array.isArray(external.specialMechanicsSeen))data.specialMechanicsSeen=[...new Set(external.specialMechanicsSeen)].filter(type=>['warp','lock','crossing'].includes(type)).sort();
|
|||
|
|
if(isPlainObject(external.quarantine))data.quarantine=deepClone(external.quarantine);
|
|||
|
|
statsDirty=true;
|
|||
|
|
}
|
|||
|
|
function mergeSnapshotIntoData(external,{finalize=true,authoritativeWorld=false}={}){
|
|||
|
|
if(!external||!isPlainObject(external.metas))return[];
|
|||
|
|
const added=[];
|
|||
|
|
for(const[id,incoming]of Object.entries(external.metas)){
|
|||
|
|
lastRevision=Math.max(lastRevision,incoming.rev||0);const current=data.metas[id];
|
|||
|
|
if(authoritativeWorld){
|
|||
|
|
clearSharedWorldJournalRow('meta',id);
|
|||
|
|
if(!current){data.metas[id]=incoming;added.push(id)}
|
|||
|
|
else if(sameMetaGeometry(current,incoming))Object.assign(current,incoming);
|
|||
|
|
else{destroyBoard(rendered.get(id));destroyStaticBoard(staticRendered.get(id));data.metas[id]=incoming;added.push(id)}
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
if(!current){data.metas[id]=incoming;added.push(id);continue}
|
|||
|
|
if(compareRevisionVersions(incoming,current)>0){
|
|||
|
|
if(sameMetaGeometry(current,incoming))Object.assign(current,incoming);
|
|||
|
|
else{destroyBoard(rendered.get(id));data.metas[id]=incoming;added.push(id)}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
for(const[id,incoming]of Object.entries(external.states||{})){
|
|||
|
|
lastRevision=Math.max(lastRevision,incoming.rev||0);if(!data.metas[id])continue;
|
|||
|
|
const current=data.states[id];let merged;
|
|||
|
|
if(authoritativeWorld){
|
|||
|
|
clearSharedWorldJournalRow('state',id);
|
|||
|
|
merged=current?.solved&&!incoming.solved?deepClone(incoming):mergeBoardStates(current,incoming);
|
|||
|
|
}else merged=mergeBoardStates(current,incoming);
|
|||
|
|
if(!current||JSON.stringify(merged)!==JSON.stringify(current)){
|
|||
|
|
if(current&&!authoritativeWorld){merged.rev=nextRevision();merged.revAuthor=sessionId}
|
|||
|
|
data.states[id]=merged;normalizedStateObjects.add(merged);if(current)markStateDirty(id);
|
|||
|
|
const board=rendered.get(id);if(board){board.drawing=null;board.solvedPathsRendered=false}
|
|||
|
|
if(data.metas[id].puzzle)sanitizeStateForPuzzle(data.metas[id],{quiet:true});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if(authoritativeWorld)applyAuthoritativeSharedGlobal(external);else mergeGlobalFields(external);
|
|||
|
|
if(finalize)resolveMergedOverlaps()
|
|||
|
|
return added.filter(id=>data.metas[id]);
|
|||
|
|
}
|
|||
|
|
function setSaveStatus(state,message){saveStatusEl.dataset.state=state;saveStatusEl.textContent=message;saveStatusEl.setAttribute('aria-label',`\u4fdd\u5b58\u72b6\u614b\uff1a${message}`)}
|
|||
|
|
function metaForStorage(m){return{id:m.id,x:m.x,y:m.y,chunks:m.chunks,level:m.level,targetLevel:m.targetLevel,seed:m.seed,axis:m.axis,entrySide:m.entrySide||null,sealedSides:[...new Set((m.sealedSides||[]).filter(side=>['N','S','W','E'].includes(side)))],puzzle:puzzleForStorage(m.puzzle),generatorVersion:m.generatorVersion||GENERATOR_VERSION,rev:m.rev||0,revAuthor:m.revAuthor||''}}
|
|||
|
|
function globalForStorage(source=data,updatedAt=trustedNow()){
|
|||
|
|
const anchor=source===data&&typeof currentCameraAnchor==='function'?currentCameraAnchor():source.cameraAnchor||null;
|
|||
|
|
return{
|
|||
|
|
schema:SAVE_SCHEMA,gameplayVersion:GAMEPLAY_DATA_VERSION,worldGeneration:WORLD_GENERATION,worldEpoch:source.worldEpoch,
|
|||
|
|
globalRev:source.globalRev||0,globalRevAuthor:source.globalRevAuthor||'',appVersion:APP_VERSION,generatorVersion:GENERATOR_VERSION,
|
|||
|
|
quarantine:source.quarantine||{},bonusEvents:source.bonusEvents||{},clockFloor:Math.max(source.clockFloor||0,updatedAt),
|
|||
|
|
cloudProfile:source.cloudProfile,cloudRevision:source.cloudRevision||0,cloudSyncPaused:source.cloudSyncPaused===true,playerName:source.playerName||null,playerPurchases:normalizePlayerPurchases(source.playerPurchases),playerEarnedScore:Number.isSafeInteger(source.playerEarnedScore)&&source.playerEarnedScore>=0?source.playerEarnedScore:0,lastReaction:REACTION_EMOJIS.includes(source.lastReaction)?source.lastReaction:'👍',worldFeedRevision:source.worldFeedRevision||0,
|
|||
|
|
nextId:source.nextId,solved:source.solved||0,score:source.score||0,bonusScore:source.bonusScore||0,bonusScoreVersion:SCORE_VERSION,
|
|||
|
|
lastSolveAt:source.lastSolveAt||0,timeAttack:source.timeAttack,timeAttackRev:source.timeAttackRev||0,timeAttackCooldowns:source.timeAttackCooldowns,
|
|||
|
|
lastTimeAttack:source.lastTimeAttack,timeAttackSuggestionsDisabled:source.timeAttackSuggestionsDisabled===true,cursorStyle:source.cursorStyle||'default',
|
|||
|
|
scoreLensEnabled:source.scoreLensEnabled===true,
|
|||
|
|
debugAllItems:source.debugAllItems===true,
|
|||
|
|
specialMechanicsSeen:[...new Set(source.specialMechanicsSeen||[])].filter(type=>['warp','lock','crossing'].includes(type)).sort(),
|
|||
|
|
cameraAnchor:anchor,selectedBoardId:source===data?(activeBoard||source.selectedBoardId||null):source.selectedBoardId||null,updatedAt,sessionId
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
function mergeGlobalRecords(current,incoming){
|
|||
|
|
if(!current)return deepClone(incoming);if(!incoming)return deepClone(current);
|
|||
|
|
if(validWorldEpoch(current.worldEpoch)&&validWorldEpoch(incoming.worldEpoch)&¤t.worldEpoch!==incoming.worldEpoch){const error=new Error('World epoch changed in another tab');error.code='STALE_WORLD_EPOCH';throw error}
|
|||
|
|
const newer=compareRevisionVersions({rev:incoming.globalRev,revAuthor:incoming.globalRevAuthor},{rev:current.globalRev,revAuthor:current.globalRevAuthor})>=0?incoming:current,
|
|||
|
|
older=newer===incoming?current:incoming,merged=deepClone(newer);
|
|||
|
|
merged.worldEpoch=incoming.worldEpoch||current.worldEpoch||null;merged.nextId=Math.max(current.nextId||1,incoming.nextId||1);
|
|||
|
|
merged.clockFloor=Math.max(current.clockFloor||0,incoming.clockFloor||0);merged.cloudRevision=Math.max(current.cloudRevision||0,incoming.cloudRevision||0);merged.updatedAt=Math.max(current.updatedAt||0,incoming.updatedAt||0);
|
|||
|
|
merged.bonusEvents={};for(const source of[current.bonusEvents||{},incoming.bonusEvents||{}])for(const[id,value]of Object.entries(source))merged.bonusEvents[id]=Math.max(merged.bonusEvents[id]||0,value||0);
|
|||
|
|
merged.bonusScore=bonusEventTotal(merged.bonusEvents);
|
|||
|
|
const personalPurchases=new Map();for(const purchase of[...(current.playerPurchases||[]),...(incoming.playerPurchases||[])]){if(!purchase?.purchaseId)continue;const prior=personalPurchases.get(purchase.purchaseId);if(!prior||(purchase.boughtAt||0)>=(prior.boughtAt||0))personalPurchases.set(purchase.purchaseId,deepClone(purchase))}merged.playerPurchases=[...personalPurchases.values()];
|
|||
|
|
merged.quarantine={};for(const source of[current.quarantine||{},incoming.quarantine||{}])for(const[id,value]of Object.entries(source)){const prior=merged.quarantine[id];if(!prior||(value?.failedAt||0)>=(prior?.failedAt||0))merged.quarantine[id]=deepClone(value)}
|
|||
|
|
if((older.lastSolveAt||0)>(merged.lastSolveAt||0))merged.lastSolveAt=older.lastSolveAt;
|
|||
|
|
if((older.timeAttackRev||0)>(merged.timeAttackRev||0)){merged.timeAttack=deepClone(older.timeAttack);merged.timeAttackRev=older.timeAttackRev;merged.timeAttackCooldowns=deepClone(older.timeAttackCooldowns);merged.lastTimeAttack=deepClone(older.lastTimeAttack)}
|
|||
|
|
else if((older.lastTimeAttack?.completedAt||0)>(merged.lastTimeAttack?.completedAt||0))merged.lastTimeAttack=deepClone(older.lastTimeAttack);
|
|||
|
|
merged.specialMechanicsSeen=[...new Set([...(current.specialMechanicsSeen||[]),...(incoming.specialMechanicsSeen||[])])].filter(type=>['warp','lock','crossing'].includes(type)).sort();
|
|||
|
|
for(const retired of['combo','comboUntil','comboExpiresAt','comboTimer','bestCombo','undo','undoStack','redoStack'])delete merged[retired];
|
|||
|
|
merged.globalRev=newer.globalRev||0;merged.globalRevAuthor=newer.globalRevAuthor||'';return merged;
|
|||
|
|
}
|
|||
|
|
function applyGlobalRecordToData(record){
|
|||
|
|
if(!record)return;
|
|||
|
|
const priorBonuses=JSON.stringify(data.bonusEvents||{});
|
|||
|
|
for(const key of['worldEpoch','globalRev','globalRevAuthor','gameplayVersion','quarantine','bonusEvents','clockFloor','cloudProfile','cloudRevision','cloudSyncPaused','playerName','playerPurchases','playerEarnedScore','lastReaction','worldFeedRevision','nextId','solved','score','bonusScore','lastSolveAt','timeAttack','timeAttackRev','timeAttackCooldowns','lastTimeAttack','timeAttackSuggestionsDisabled','cursorStyle','scoreLensEnabled','debugAllItems','specialMechanicsSeen','cameraAnchor','selectedBoardId','updatedAt'])if(Object.prototype.hasOwnProperty.call(record,key))data[key]=deepClone(record[key]);
|
|||
|
|
data.bonusScore=bonusEventTotal(data.bonusEvents);lastRevision=Math.max(lastRevision,data.globalRev||0,data.clockFloor?data.clockFloor*1000:0);
|
|||
|
|
if(priorBonuses!==JSON.stringify(data.bonusEvents||{}))statsDirty=true;
|
|||
|
|
}
|
|||
|
|
function staleWorldEpochError(){const error=new Error('\u5225\u306e\u30bf\u30d6\u3067\u30d5\u30a3\u30fc\u30eb\u30c9\u304c\u7f6e\u304d\u63db\u3048\u3089\u308c\u307e\u3057\u305f\u3002');error.code='STALE_WORLD_EPOCH';return error}
|
|||
|
|
function stateForStorage(state){return AppLogic.stateForStorage(state,{scoreVersion:SCORE_VERSION,normalizeState})}
|
|||
|
|
function summarizeBoardV2(meta,state,epoch=data.worldEpoch){
|
|||
|
|
const puzzle=meta?.puzzle,special=puzzle?.specialCells||{},store=state?.store;
|
|||
|
|
let storeCell=null;
|
|||
|
|
if(store&&Number.isInteger(store.pathIndex)&&Number.isInteger(store.cellIndex)){
|
|||
|
|
const cell=state.paths?.[store.pathIndex]?.cells?.[store.cellIndex];if(cell)storeCell=[cell[0],cell[1]];
|
|||
|
|
}
|
|||
|
|
return{
|
|||
|
|
epoch,id:meta.id,number:Number(meta.id.slice(1))||0,x:meta.x,y:meta.y,chunks:meta.chunks.map(chunk=>[chunk[0],chunk[1]]),level:meta.level,targetLevel:meta.targetLevel,
|
|||
|
|
seed:meta.seed,axis:meta.axis,entrySide:meta.entrySide||null,sealedSides:[...(meta.sealedSides||[])],metaRev:meta.rev||0,stateRev:state?.rev||0,metaRevAuthor:meta.revAuthor||'',stateRevAuthor:state?.revAuthor||'',
|
|||
|
|
solved:state?.solved===true,expanded:state?.expanded===true,solvedBy:state?.solvedBy||null,solvedById:state?.solvedById||null,solvedAt:Number.isFinite(state?.solvedAt)?state.solvedAt:null,scoreAwarded:Math.max(0,Number(state?.scoreAwarded)||0),
|
|||
|
|
hasProgress:Boolean(state?.paths?.length||state?.specialProgress?.crossings?.length),
|
|||
|
|
specialFlags:{crossing:Boolean(special.crossings?.length),warp:Boolean(special.warps?.length),lock:Boolean(special.locks?.length)},
|
|||
|
|
shop:store?{cell:storeCell,itemIds:Array.isArray(store.itemIds)?[...store.itemIds]:[],purchases:(store.purchases||[]).map(purchase=>({id:purchase.id,paidCost:purchase.paidCost||0,boughtAt:purchase.boughtAt||0}))}:null
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
function puzzleRecordV2(meta,epoch=data.worldEpoch){return{epoch,id:meta.id,metaRev:meta.rev||0,revAuthor:meta.revAuthor||'',generatorVersion:meta.generatorVersion||GENERATOR_VERSION,puzzle:puzzleForStorage(meta.puzzle)}}
|
|||
|
|
function stateRecordV2(id,state,epoch=data.worldEpoch){return{epoch,id,stateRev:state?.rev||0,revAuthor:state?.revAuthor||'',value:stateForStorage(state)}}
|
|||
|
|
function metaFromV2Records(index,puzzle){
|
|||
|
|
if(!index||!puzzle||index.id!==puzzle.id||index.metaRev!==puzzle.metaRev)return null;
|
|||
|
|
return normalizeMeta(index.id,{id:index.id,x:index.x,y:index.y,chunks:index.chunks,level:index.level,targetLevel:index.targetLevel,seed:index.seed,axis:index.axis,entrySide:index.entrySide||null,sealedSides:index.sealedSides||[],puzzle:puzzle.puzzle,generatorVersion:puzzle.generatorVersion||GENERATOR_VERSION,rev:puzzle.metaRev,revAuthor:puzzle.revAuthor||''});
|
|||
|
|
}
|
|||
|
|
function stateFromV2Record(index,state){
|
|||
|
|
if(!index||!state||index.id!==state.id||index.stateRev!==state.stateRev)return null;
|
|||
|
|
return normalizeState(state.value);
|
|||
|
|
}
|
|||
|
|
function summaryStateFromIndex(index){
|
|||
|
|
const shop=index?.shop;
|
|||
|
|
return{
|
|||
|
|
paths:[],specialProgress:{crossings:[]},solved:index?.solved===true,expanded:index?.expanded===true,expansionRetryRound:0,solvedBy:typeof index?.solvedBy==='string'?index.solvedBy:null,solvedById:typeof index?.solvedById==='string'?index.solvedById:null,solvedAt:Number.isFinite(index?.solvedAt)?index.solvedAt:null,
|
|||
|
|
scoreAwarded:Math.max(0,Number(index?.scoreAwarded)||0),scoreVersion:SCORE_VERSION,rewardIdentity:null,rewardCoefficient:null,
|
|||
|
|
store:shop?{owner:'',pathIndex:-1,cellIndex:-1,itemIds:[...(shop.itemIds||[])],purchases:deepClone(shop.purchases||[]),summaryCell:Array.isArray(shop.cell)?[...shop.cell]:null}:null,
|
|||
|
|
rev:index?.stateRev||0,revAuthor:index?.stateRevAuthor||index?.revAuthor||'',_summaryOnly:true
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
function fieldBoundsFromMetas(metas){
|
|||
|
|
if(metas===data?.metas&&fieldBoundsCache?.revision===worldGeometryRevision)return{...fieldBoundsCache.bounds};
|
|||
|
|
let minX=0,minY=0,maxX=1,maxY=1,found=false;
|
|||
|
|
for(const meta of Object.values(metas||{}))for(const[dx,dy]of meta?.chunks||[]){
|
|||
|
|
const x=meta.x+dx,y=meta.y+dy;if(!found){minX=x;minY=y;maxX=x+1;maxY=y+1;found=true}else{minX=Math.min(minX,x);minY=Math.min(minY,y);maxX=Math.max(maxX,x+1);maxY=Math.max(maxY,y+1)}
|
|||
|
|
}
|
|||
|
|
const bounds={minX,minY,maxX,maxY};if(metas===data?.metas)fieldBoundsCache={revision:worldGeometryRevision,bounds};return{...bounds};
|
|||
|
|
}
|
|||
|
|
function metaRowsForStorage(ids=Object.keys(data.metas)){return ids.map(id=>data.metas[id]&&metaForStorage(data.metas[id])).filter(Boolean)}
|
|||
|
|
function stateRowsForStorage(ids=Object.keys(data.states)){return ids.map(id=>data.states[id]&&({id,value:stateForStorage(data.states[id])})).filter(Boolean)}
|
|||
|
|
async function cloudRowsForStorage(metaIds,stateIds){
|
|||
|
|
const metaSet=new Set(metaIds||[]),stateSet=new Set(stateIds||[]),allIds=[...new Set([...metaSet,...stateSet])],metas=[],states=[];
|
|||
|
|
if(!allIds.length)return{metas,states};
|
|||
|
|
if(activeStorageFormat!==FIELD_STORAGE_FORMAT||!idbAvailable)return{metas:metaRowsForStorage([...metaSet]),states:stateRowsForStorage([...stateSet])};
|
|||
|
|
const db=await openWorldDb(),tx=db.transaction(['control','boardIndex','boardPuzzles','boardStates'],'readonly'),done=transactionDone(tx),key=id=>[data.worldEpoch,id],
|
|||
|
|
[control,indexes,puzzles,stateRecords]=await Promise.all([
|
|||
|
|
requestValue(tx.objectStore('control').get('active')),
|
|||
|
|
Promise.all(allIds.map(id=>requestValue(tx.objectStore('boardIndex').get(key(id))))),
|
|||
|
|
Promise.all(allIds.map(id=>requestValue(tx.objectStore('boardPuzzles').get(key(id))))),
|
|||
|
|
Promise.all(allIds.map(id=>requestValue(tx.objectStore('boardStates').get(key(id)))))
|
|||
|
|
]);await done;
|
|||
|
|
if(!control||control.activeEpoch!==data.worldEpoch||control.activeFormat!==FIELD_STORAGE_FORMAT)throw staleWorldEpochError();
|
|||
|
|
for(let position=0;position<allIds.length;position++){
|
|||
|
|
const id=allIds[position],index=indexes[position],puzzle=puzzles[position],state=stateRecords[position];
|
|||
|
|
if(metaSet.has(id)){
|
|||
|
|
const inMemory=data.metas[id];
|
|||
|
|
if(inMemory?.puzzle)metas.push(metaForStorage(inMemory));
|
|||
|
|
else{
|
|||
|
|
if(!index||!puzzle||index.metaRev!==puzzle.metaRev)throw new Error(`Cloud metadata detail is unavailable: ${id}`);
|
|||
|
|
const restored=normalizeMeta(id,{...index,sealedSides:index.sealedSides||[],puzzle:puzzle.puzzle,generatorVersion:puzzle.generatorVersion||GENERATOR_VERSION,rev:puzzle.metaRev,revAuthor:puzzle.revAuthor||index.metaRevAuthor||''});
|
|||
|
|
if(!restored)throw new Error(`Cloud metadata detail is invalid: ${id}`);metas.push(metaForStorage(restored));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if(stateSet.has(id)){
|
|||
|
|
const inMemory=data.states[id];
|
|||
|
|
if(inMemory&&!inMemory._summaryOnly)states.push({id,value:stateForStorage(inMemory)});
|
|||
|
|
else{if(!index||!state||index.stateRev!==state.stateRev)throw new Error(`Cloud state detail is unavailable: ${id}`);states.push({id,value:stateForStorage(state.value)})}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return{metas,states};
|
|||
|
|
}
|
|||
|
|
function readStorageRevision(){
|
|||
|
|
try{const value=JSON.parse(safeLocalGet(storageRevisionKey)||'null');return isPlainObject(value)?value:{}}catch(_){return{}}
|
|||
|
|
}
|
|||
|
|
function updateStorageRevision(patch){
|
|||
|
|
const current=readStorageRevision(),next={
|
|||
|
|
databaseUpdatedAt:Math.max(current.databaseUpdatedAt||0,patch.databaseUpdatedAt||0),
|
|||
|
|
mirrorUpdatedAt:Math.max(current.mirrorUpdatedAt||0,patch.mirrorUpdatedAt||0)
|
|||
|
|
};
|
|||
|
|
return safeLocalSet(storageRevisionKey,JSON.stringify(next));
|
|||
|
|
}
|
|||
|
|
let recoveryJournalSeq=-1;
|
|||
|
|
let recoveryWalPromise=Promise.resolve(),recoveryWalFailed=false;
|
|||
|
|
function persistRecoveryJournalToDb(journal){
|
|||
|
|
if(!idbAvailable||!journal)return Promise.resolve(false);
|
|||
|
|
const run=async()=>{
|
|||
|
|
if(!validWorldEpoch(data?.worldEpoch))return false;
|
|||
|
|
const db=await openWorldDb(),tx=db.transaction('recoveryV2','readwrite'),done=transactionDone(tx),key=`journal:${sessionId}`;
|
|||
|
|
tx.objectStore('recoveryV2').put({epoch:data.worldEpoch,key,value:journal});await done;recoveryWalFailed=false;perfCount('recoveryWalWrites');return true;
|
|||
|
|
};
|
|||
|
|
const result=recoveryWalPromise.then(run,run).catch(error=>{recoveryWalFailed=true;console.warn('BEND FIELD: recovery WAL failed',error);if(document.visibilityState==='visible')showStatus('\u7dca\u6025\u30d0\u30c3\u30af\u30a2\u30c3\u30d7\u3092\u4fdd\u5b58\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002',{retry:true,fresh:false});return false});recoveryWalPromise=result.then(()=>{},()=>{});return result;
|
|||
|
|
}
|
|||
|
|
function writeDirtyRecoveryJournal(){
|
|||
|
|
if(!hasPendingPersistence())return false;
|
|||
|
|
const updatedAt=Math.max(trustedNow(),(data.updatedAt||0)+1),global=globalForStorage(data,updatedAt),journal={
|
|||
|
|
schema:SAVE_SCHEMA,worldGeneration:WORLD_GENERATION,worldEpoch:data.worldEpoch,updatedAt,sessionId,seq:globalChangeSeq,global:{...global,cloudPending:currentCloudPending()},
|
|||
|
|
metas:metaRowsForStorage([...dirtyMetaIds]),states:stateRowsForStorage([...dirtyStateIds]),deleted:[...deletedBoardIds].map(id=>({id,rev:deletedBoardRevisions.get(id)||updatedAt*1000,revAuthor:deletedBoardAuthors.get(id)||sessionId}))
|
|||
|
|
};
|
|||
|
|
try{
|
|||
|
|
void persistRecoveryJournalToDb(journal);
|
|||
|
|
const json=JSON.stringify(journal);if(json.length>LOCAL_MIRROR_MAX_BYTES){recoveryJournalSeq=globalChangeSeq;perfCount('recoveryJournalOversize');return idbAvailable}
|
|||
|
|
if(!safeLocalSet(sessionRecoveryJournalKey,json))return false;recoveryJournalSeq=globalChangeSeq;perfCount('recoveryJournalWrites');return true;
|
|||
|
|
}catch(_){return false}
|
|||
|
|
}
|
|||
|
|
function clearRecoveryJournalIfCovered(committedSeq,coveredJournals=[]){
|
|||
|
|
let cleared=false;const cleanupSessions=[];
|
|||
|
|
if(recoveryJournalSeq>=0&&recoveryJournalSeq<=committedSeq){if(safeLocalRemove(sessionRecoveryJournalKey)){recoveryJournalSeq=-1;cleanupSessions.push(sessionId);cleared=true}}
|
|||
|
|
for(const journal of coveredJournals)if(journal?._storageKey&&safeLocalRemove(journal._storageKey)){if(journal.sessionId)cleanupSessions.push(journal.sessionId);cleared=true}
|
|||
|
|
if(coveredJournals.length){const coveredKeys=new Set(coveredJournals.map(journal=>journal._storageKey));recoveryJournalsToCover=recoveryJournalsToCover.filter(journal=>!coveredKeys.has(journal._storageKey))}
|
|||
|
|
if(cleanupSessions.length)void deleteRecoveryCoverageMarkers(cleanupSessions);
|
|||
|
|
return cleared;
|
|||
|
|
}
|
|||
|
|
let mirrorRetryAt=0,mirrorCleanupHandle=0,mirrorCleanupQueue=[],mirrorBuildSerial=0;
|
|||
|
|
function scheduleMirrorChunkCleanup(keepGeneration=null){
|
|||
|
|
const keepPrefix=keepGeneration?`${mirrorChunkPrefix}${keepGeneration}:`:null;
|
|||
|
|
mirrorCleanupQueue=safeLocalKeys(mirrorChunkPrefix).filter(key=>!keepPrefix||!key.startsWith(keepPrefix));
|
|||
|
|
if(mirrorCleanupHandle||!mirrorCleanupQueue.length)return;
|
|||
|
|
const run=deadline=>{
|
|||
|
|
mirrorCleanupHandle=0;let removed=0;
|
|||
|
|
while(mirrorCleanupQueue.length&&(deadline?.didTimeout?removed<1:typeof deadline?.timeRemaining==='function'?deadline.timeRemaining()>4:removed<1)){safeLocalRemove(mirrorCleanupQueue.shift());removed++}
|
|||
|
|
if(mirrorCleanupQueue.length)mirrorCleanupHandle=typeof requestIdleCallback==='function'?requestIdleCallback(run,{timeout:MIRROR_IDLE_TIMEOUT}):setTimeout(()=>run({didTimeout:true,timeRemaining:()=>0}),0);
|
|||
|
|
};
|
|||
|
|
mirrorCleanupHandle=typeof requestIdleCallback==='function'?requestIdleCallback(run,{timeout:MIRROR_IDLE_TIMEOUT}):setTimeout(()=>run({didTimeout:true,timeRemaining:()=>0}),0);
|
|||
|
|
}
|
|||
|
|
function writeCompactMirror(snapshot=snapshotForStorage(data.updatedAt||trustedNow()),{authoritative=false}={}){
|
|||
|
|
const started=perfStart();
|
|||
|
|
try{
|
|||
|
|
const head=readStorageRevision(),updatedAt=snapshot?.updatedAt||0;
|
|||
|
|
if(Math.max(head.databaseUpdatedAt||0,head.mirrorUpdatedAt||0)>updatedAt){perfCount('mirrorStaleSkips');if(authoritative){mirrorRetryAt=Date.now()+1000;return false}return true}
|
|||
|
|
const json=JSON.stringify(snapshot);
|
|||
|
|
perfGauge('mirrorBytes',json.length);
|
|||
|
|
if(json.length>LOCAL_MIRROR_MAX_BYTES){perfCount('mirrorOversize');mirrorRetryAt=Date.now()+5*60*1000;return false}
|
|||
|
|
const written=safeLocalSet(storageKey,json);if(written){mirrorRetryAt=0;updateStorageRevision({mirrorUpdatedAt:updatedAt});scheduleMirrorChunkCleanup();perfCount('mirrorWrites')}else mirrorRetryAt=Date.now()+60*1000;return written;
|
|||
|
|
}catch(error){mirrorRetryAt=Date.now()+60*1000;console.warn('BEND FIELD: compact mirror failed',error);return false}
|
|||
|
|
finally{perfEnd('writeCompactMirror',started)}
|
|||
|
|
}
|
|||
|
|
function* jsonValueFragments(value){
|
|||
|
|
if(value===null){yield'null';return}
|
|||
|
|
const type=typeof value;
|
|||
|
|
if(type==='string'){yield JSON.stringify(value);return}
|
|||
|
|
if(type==='number'){yield Number.isFinite(value)?String(value):'null';return}
|
|||
|
|
if(type==='boolean'){yield value?'true':'false';return}
|
|||
|
|
if(type!=='object'){yield'null';return}
|
|||
|
|
if(Array.isArray(value)){
|
|||
|
|
yield'[';for(let index=0;index<value.length;index++){if(index)yield',';yield*jsonValueFragments(value[index])}yield']';return;
|
|||
|
|
}
|
|||
|
|
yield'{';let first=true;
|
|||
|
|
for(const key of Object.keys(value)){const entry=value[key];if(entry===undefined||typeof entry==='function'||typeof entry==='symbol')continue;if(!first)yield',';first=false;yield JSON.stringify(key);yield':';yield*jsonValueFragments(entry)}
|
|||
|
|
yield'}';
|
|||
|
|
}
|
|||
|
|
function mirrorBuildGeneration(updatedAt,seq){return`${Math.max(0,updatedAt).toString(36)}-${Math.max(0,seq).toString(36)}-${sessionId.replace(/[^A-Za-z0-9_-]/g,'').slice(-20)}-${(++mirrorBuildSerial).toString(36)}`}
|
|||
|
|
function discardMirrorBuild(build=mirrorBuild){if(build?.generation){const committed=parseMirrorManifest(safeLocalGet(storageKey));scheduleMirrorChunkCleanup(committed?.generation||null)}if(build===mirrorBuild)mirrorBuild=null}
|
|||
|
|
function initializeMirrorSerialization(build){
|
|||
|
|
const snapshot={...globalForStorage(data,build.updatedAt),cloudPending:currentCloudPending(),metas:build.metas,states:build.states};
|
|||
|
|
build.serializer=jsonValueFragments(snapshot);build.pendingChunk='';build.chunkIndex=0;build.bytes=0;build.serializerDone=false;build.generation=mirrorBuildGeneration(build.updatedAt,build.seq);build.cleanupKeys=safeLocalKeys(mirrorChunkPrefix);safeLocalRemove(storageKey);build.stage=build.cleanupKeys.length?'cleanup':'serialize';
|
|||
|
|
}
|
|||
|
|
function appendMirrorFragment(build,fragment){
|
|||
|
|
let offset=0;
|
|||
|
|
while(offset<fragment.length){const capacity=MIRROR_CHUNK_BYTES-build.pendingChunk.length,take=Math.min(capacity,fragment.length-offset);build.pendingChunk+=fragment.slice(offset,offset+take);offset+=take;if(build.pendingChunk.length>=MIRROR_CHUNK_BYTES)return offset}
|
|||
|
|
return offset;
|
|||
|
|
}
|
|||
|
|
function writeMirrorBuildChunk(build){
|
|||
|
|
if(!build.pendingChunk)return true;
|
|||
|
|
const started=perfStart(),key=mirrorChunkKey(build.generation,build.chunkIndex),chunk=build.pendingChunk;
|
|||
|
|
if(!safeLocalSet(key,chunk)){perfEnd('mirrorChunkWrite',started);return false}
|
|||
|
|
build.pendingChunk='';build.chunkIndex++;build.bytes+=chunk.length;perfGauge('mirrorChunkWriteMs',perfNow()-started);perfEnd('mirrorChunkWrite',started);perfCount('mirrorChunkWrites');return true;
|
|||
|
|
}
|
|||
|
|
function commitMirrorBuild(build){
|
|||
|
|
const head=readStorageRevision();
|
|||
|
|
if(Math.max(head.databaseUpdatedAt||0,head.mirrorUpdatedAt||0)>build.updatedAt){perfCount('mirrorStaleSkips');discardMirrorBuild(build);return true}
|
|||
|
|
const manifest={format:MIRROR_CHUNK_FORMAT,generation:build.generation,chunks:build.chunkIndex,bytes:build.bytes,updatedAt:build.updatedAt};
|
|||
|
|
if(!safeLocalSet(storageKey,JSON.stringify(manifest)))return false;
|
|||
|
|
mirrorRetryAt=0;updateStorageRevision({mirrorUpdatedAt:build.updatedAt});perfGauge('mirrorBytes',build.bytes);perfGauge('mirrorChunks',build.chunkIndex);perfCount('mirrorWrites');scheduleMirrorChunkCleanup(build.generation);return true;
|
|||
|
|
}
|
|||
|
|
let mirrorIdleHandle=0,mirrorDelayTimer=0,mirrorCheckpointDirty=false,mirrorBuild=null;
|
|||
|
|
function* storageEntryIterator(source){for(const id in source)if(Object.prototype.hasOwnProperty.call(source,id))yield[id,source[id]]}
|
|||
|
|
function cancelMirrorCheckpoint(clearDirty=false){
|
|||
|
|
if(mirrorDelayTimer){clearTimeout(mirrorDelayTimer);mirrorDelayTimer=0}
|
|||
|
|
if(mirrorIdleHandle){if(typeof cancelIdleCallback==='function')cancelIdleCallback(mirrorIdleHandle);else clearTimeout(mirrorIdleHandle);mirrorIdleHandle=0}
|
|||
|
|
discardMirrorBuild();if(clearDirty)mirrorCheckpointDirty=false;
|
|||
|
|
}
|
|||
|
|
function runMirrorCheckpoint(deadline=null){
|
|||
|
|
mirrorIdleHandle=0;
|
|||
|
|
if(!idbAvailable||!mirrorCheckpointDirty)return;
|
|||
|
|
const interacting=Boolean(pan||pinch||[...rendered.values()].some(board=>board.drawing?.pointerId!=null));
|
|||
|
|
if(interacting||hasPendingPersistence()){discardMirrorBuild();scheduleMirrorCheckpoint();return}
|
|||
|
|
if(Date.now()<mirrorRetryAt){scheduleMirrorCheckpoint(mirrorRetryAt-Date.now());return}
|
|||
|
|
const checkpointUpdatedAt=data.updatedAt||0,checkpointSeq=globalChangeSeq;
|
|||
|
|
if(!checkpointUpdatedAt){scheduleMirrorCheckpoint();return}
|
|||
|
|
if(!mirrorBuild||mirrorBuild.updatedAt!==checkpointUpdatedAt||mirrorBuild.seq!==checkpointSeq){discardMirrorBuild();mirrorBuild={updatedAt:checkpointUpdatedAt,seq:checkpointSeq,metaIterator:storageEntryIterator(data.metas),stateIterator:storageEntryIterator(data.states),metaDone:false,stateDone:false,metas:{},states:{},slices:0,stage:'rows'}}
|
|||
|
|
const build=mirrorBuild,sliceStarted=perfNow(),canContinue=processed=>deadline?.didTimeout?processed<64:typeof deadline?.timeRemaining==='function'?deadline.timeRemaining()>4:processed<64;let processed=0;build.slices++;
|
|||
|
|
if(build.stage==='rows'){
|
|||
|
|
while(!build.metaDone&&canContinue(processed++)){const next=build.metaIterator.next();if(next.done){build.metaDone=true;break}const[id,meta]=next.value;if(meta)build.metas[id]=metaForStorage(meta)}
|
|||
|
|
while(build.metaDone&&!build.stateDone&&canContinue(processed++)){const next=build.stateIterator.next();if(next.done){build.stateDone=true;break}const[id,state]=next.value;if(state)build.states[id]=stateForStorage(state)}
|
|||
|
|
if(build.metaDone&&build.stateDone)initializeMirrorSerialization(build);
|
|||
|
|
}
|
|||
|
|
if(build.stage==='cleanup'){
|
|||
|
|
if(build.cleanupKeys.length){safeLocalRemove(build.cleanupKeys.shift());perfCount('mirrorChunkRemovals');perfGauge('mirrorBuildSliceMs',perfNow()-sliceStarted);scheduleMirrorCheckpoint(0);return}
|
|||
|
|
build.stage='serialize';
|
|||
|
|
}
|
|||
|
|
if(build.stage==='serialize'){
|
|||
|
|
let fragmentOffset=build.fragmentOffset||0,fragment=build.fragment||'';
|
|||
|
|
while(canContinue(processed++)){
|
|||
|
|
if(fragmentOffset<fragment.length){fragmentOffset+=appendMirrorFragment(build,fragment.slice(fragmentOffset));if(build.pendingChunk.length>=MIRROR_CHUNK_BYTES){build.fragment=fragment;build.fragmentOffset=fragmentOffset;if(!writeMirrorBuildChunk(build)){mirrorRetryAt=Date.now()+60*1000;discardMirrorBuild(build);scheduleMirrorCheckpoint(mirrorRetryAt-Date.now());return}perfGauge('mirrorBuildSliceMs',perfNow()-sliceStarted);scheduleMirrorCheckpoint(0);return}continue}
|
|||
|
|
const next=build.serializer.next();if(next.done){build.serializerDone=true;break}fragment=next.value;fragmentOffset=0;
|
|||
|
|
}
|
|||
|
|
build.fragment=fragment;build.fragmentOffset=fragmentOffset;
|
|||
|
|
if(build.serializerDone){if(build.pendingChunk){if(!writeMirrorBuildChunk(build)){mirrorRetryAt=Date.now()+60*1000;discardMirrorBuild(build);scheduleMirrorCheckpoint(mirrorRetryAt-Date.now());return}perfGauge('mirrorBuildSliceMs',perfNow()-sliceStarted);scheduleMirrorCheckpoint(0);return}build.stage='commit'}
|
|||
|
|
}
|
|||
|
|
perfGauge('mirrorBuildSliceMs',perfNow()-sliceStarted);perfGauge('mirrorBuildSlices',build.slices);
|
|||
|
|
if(hasPendingPersistence()||data.updatedAt!==build.updatedAt||globalChangeSeq!==build.seq){discardMirrorBuild(build);scheduleMirrorCheckpoint();return}
|
|||
|
|
if(build.stage!=='commit'){scheduleMirrorCheckpoint(0);return}
|
|||
|
|
mirrorCheckpointDirty=false;
|
|||
|
|
if(!commitMirrorBuild(build)){mirrorCheckpointDirty=true;mirrorRetryAt=Date.now()+60*1000;discardMirrorBuild(build)}else mirrorBuild=null;
|
|||
|
|
if(mirrorCheckpointDirty)scheduleMirrorCheckpoint(Math.max(MIRROR_IDLE_DELAY,mirrorRetryAt-Date.now()));
|
|||
|
|
}
|
|||
|
|
function scheduleMirrorCheckpoint(delay=MIRROR_IDLE_DELAY){
|
|||
|
|
if(!idbAvailable||activeStorageFormat===FIELD_STORAGE_FORMAT)return;mirrorCheckpointDirty=true;
|
|||
|
|
if(mirrorDelayTimer||mirrorIdleHandle)return;
|
|||
|
|
mirrorDelayTimer=setTimeout(()=>{
|
|||
|
|
mirrorDelayTimer=0;
|
|||
|
|
if(typeof requestIdleCallback==='function')mirrorIdleHandle=requestIdleCallback(runMirrorCheckpoint,{timeout:MIRROR_IDLE_TIMEOUT});
|
|||
|
|
else mirrorIdleHandle=setTimeout(()=>runMirrorCheckpoint({didTimeout:true,timeRemaining:()=>0}),0);
|
|||
|
|
},Math.max(0,delay));
|
|||
|
|
}
|
|||
|
|
async function persistDirtyToDb(options={}){
|
|||
|
|
const{skipCloud=false}=options;
|
|||
|
|
if(options.lifecycle!==true&&globalThis.document?.body?.classList?.contains?.('is-interacting')){perfCount('savesDeferredDuringInteraction');await waitForInteractionSettle(null)}
|
|||
|
|
if(globalThis.document?.body?.classList?.contains?.('is-interacting'))perfCount('persistenceDuringInteraction');
|
|||
|
|
const started=perfStart();pruneAndCount();
|
|||
|
|
if(!hasPendingPersistence()){perfEnd('persistDirtyToDb',started);return{updatedAt:data.updatedAt||data.clockFloor||0,count:0,skipped:true}}
|
|||
|
|
data.worldEpoch=validWorldEpoch(data.worldEpoch)?data.worldEpoch:storedWorldEpoch()||createWorldEpoch();rememberWorldEpoch(data.worldEpoch);
|
|||
|
|
const metaIds=[...dirtyMetaIds],stateIds=[...dirtyStateIds],deleted=[...deletedBoardIds],
|
|||
|
|
deletedVersions=new Map(deleted.map(id=>[id,{rev:deletedBoardRevisions.get(id)||0,revAuthor:deletedBoardAuthors.get(id)||sessionId}])),
|
|||
|
|
outboxDeletes=[...cloudOutboxDeleteKeys],coveredJournals=[...recoveryJournalsToCover],currentRecoverySeq=recoveryJournalSeq,globalSeq=globalChangeSeq,globalChanged=cloudJournalGlobalChanged,
|
|||
|
|
metaVersions=new Map(metaIds.map(id=>[id,revisionVersion(data.metas[id])])),stateVersions=new Map(stateIds.map(id=>[id,revisionVersion(data.states[id])])),
|
|||
|
|
updatedAt=trustedNow(),global=globalForStorage(data,updatedAt),metaRows=metaRowsForStorage(metaIds),stateRows=stateRowsForStorage(stateIds),
|
|||
|
|
metaRowMap=new Map(metaRows.map(row=>[row.id,row])),stateRowMap=new Map(stateRows.map(row=>[row.id,row])),committedMetas=new Map(),committedStates=new Map(),committedDeleted=new Set();
|
|||
|
|
let committedGlobal=global,reconciledPersistenceConflict=false;
|
|||
|
|
if(idbAvailable&¤tRecoverySeq>=0)await recoveryWalPromise;
|
|||
|
|
if(!idbAvailable){
|
|||
|
|
const mirror=snapshotForStorage(updatedAt);
|
|||
|
|
if(!writeCompactMirror(mirror,{authoritative:true}))throw storageAccessError||new Error('\u30d6\u30e9\u30a6\u30b6\u306e\u4fdd\u5b58\u6a5f\u80fd\u3092\u5229\u7528\u3067\u304d\u307e\u305b\u3093\u3002');
|
|||
|
|
for(const id of deleted)committedDeleted.add(id);
|
|||
|
|
}else{
|
|||
|
|
const db=await openWorldDb(),tx=db.transaction(['control','worlds','boardIndex','boardPuzzles','boardStates','outboxV2','recoveryV2','tombstonesV2'],'readwrite'),done=transactionDone(tx),
|
|||
|
|
controlStore=tx.objectStore('control'),worldsStore=tx.objectStore('worlds'),indexStore=tx.objectStore('boardIndex'),puzzleStore=tx.objectStore('boardPuzzles'),stateV2Store=tx.objectStore('boardStates'),
|
|||
|
|
outboxV2Store=tx.objectStore('outboxV2'),recoveryV2Store=tx.objectStore('recoveryV2'),tombstoneV2Store=tx.objectStore('tombstonesV2'),
|
|||
|
|
affectedIds=[...new Set([...metaIds,...stateIds,...deleted])],
|
|||
|
|
rowReads=affectedIds.map(id=>{const key=[data.worldEpoch,id];return Promise.all([requestValue(indexStore.get(key)),requestValue(puzzleStore.get(key)),requestValue(stateV2Store.get(key)),requestValue(tombstoneV2Store.get(key))])}),
|
|||
|
|
[controlRow,storedWorldRow,existingRows]=await Promise.all([requestValue(controlStore.get('active')),requestValue(worldsStore.get(data.worldEpoch)),Promise.all(rowReads)]),
|
|||
|
|
v2Active=controlRow?.activeFormat===FIELD_STORAGE_FORMAT&&controlRow.activeEpoch===data.worldEpoch;
|
|||
|
|
if(!v2Active||!storedWorldRow||storedWorldRow.status!=='active'){try{tx.abort()}catch(_){}await done.catch(()=>{});throw staleWorldEpochError()}
|
|||
|
|
committedGlobal=mergeGlobalRecords(storedWorldRow.global,global);
|
|||
|
|
for(let index=0;index<affectedIds.length;index++){
|
|||
|
|
const id=affectedIds[index],[existingIndex,existingPuzzle,existingStateRecord,existingTombstone]=existingRows[index],
|
|||
|
|
existingMeta=metaFromV2Records(existingIndex,existingPuzzle),existingState=stateFromV2Record(existingIndex,existingStateRecord),
|
|||
|
|
localMeta=metaRowMap.get(id),localStateRow=stateRowMap.get(id),localState=localStateRow?.value,key=[data.worldEpoch,id];
|
|||
|
|
if(deletedVersions.has(id)){
|
|||
|
|
const version=deletedVersions.get(id),incomingTombstone={id,...version,worldEpoch:data.worldEpoch,deletedAt:updatedAt},tombstone=newerRevisionValue(existingTombstone,incomingTombstone);
|
|||
|
|
if(compareRevisionVersions(tombstone,existingMeta)>=0&&compareRevisionVersions(tombstone,existingState)>=0){
|
|||
|
|
indexStore.delete(key);puzzleStore.delete(key);stateV2Store.delete(key);tombstoneV2Store.put({...tombstone,epoch:data.worldEpoch});
|
|||
|
|
committedMetas.set(id,null);committedStates.set(id,null);committedDeleted.add(id);
|
|||
|
|
}else{committedMetas.set(id,existingMeta||null);committedStates.set(id,existingState||null)}
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
const metaWinner=localMeta&&(!existingMeta||compareRevisionVersions(localMeta,existingMeta)>=0)?localMeta:existingMeta||null,
|
|||
|
|
tombstoneBlocksMeta=existingTombstone&&compareRevisionVersions(existingTombstone,metaWinner)>=0;
|
|||
|
|
if(tombstoneBlocksMeta||!metaWinner){
|
|||
|
|
if(tombstoneBlocksMeta){indexStore.delete(key);puzzleStore.delete(key);stateV2Store.delete(key);committedDeleted.add(id)}
|
|||
|
|
committedMetas.set(id,null);committedStates.set(id,null);continue;
|
|||
|
|
}
|
|||
|
|
committedMetas.set(id,metaWinner);
|
|||
|
|
const stateWinner=localState&&(!existingState||compareRevisionVersions(localState,existingState)>=0)?localState:existingState||null;
|
|||
|
|
if(stateWinner&&(!existingTombstone||compareRevisionVersions(stateWinner,existingTombstone)>0)){committedStates.set(id,stateWinner);tombstoneV2Store.delete(key)}
|
|||
|
|
else{stateV2Store.delete(key);committedStates.set(id,null)}
|
|||
|
|
const normalizedMeta=normalizeMeta(id,metaWinner),normalizedState=normalizeState(stateWinner);
|
|||
|
|
if(!normalizedMeta){try{tx.abort()}catch(_){}await done.catch(()=>{});throw new Error(`Cannot persist invalid board ${id}.`)}
|
|||
|
|
if(localMeta||!existingPuzzle)puzzleStore.put(puzzleRecordV2(normalizedMeta,data.worldEpoch));
|
|||
|
|
if(localStateRow||!existingStateRecord)stateV2Store.put(stateRecordV2(id,normalizedState,data.worldEpoch));
|
|||
|
|
indexStore.put(summarizeBoardV2(normalizedMeta,normalizedState,data.worldEpoch));tombstoneV2Store.delete(key);
|
|||
|
|
}
|
|||
|
|
for(const key of outboxDeletes)outboxV2Store.delete([data.worldEpoch,key]);
|
|||
|
|
if(cloudApiEnabled){
|
|||
|
|
for(const id of metaIds)if(cloudJournalMetaIds.has(id)){const key=`meta:${id}`,row={key,type:'meta',id,epoch:data.worldEpoch};outboxV2Store.delete([data.worldEpoch,`deleted:${id}`]);outboxV2Store.put(row)}
|
|||
|
|
for(const id of stateIds)if(cloudJournalStateIds.has(id)){const key=`state:${id}`,row={key,type:'state',id,epoch:data.worldEpoch};outboxV2Store.delete([data.worldEpoch,`deleted:${id}`]);outboxV2Store.put(row)}
|
|||
|
|
for(const id of committedDeleted)if(cloudJournalDeletedIds.has(id)){const version=deletedVersions.get(id)||{},key=`deleted:${id}`,row={epoch:data.worldEpoch,key,type:'deleted',id,rev:version.rev||0,revAuthor:version.revAuthor||'',worldEpoch:data.worldEpoch};outboxV2Store.delete([data.worldEpoch,`meta:${id}`]);outboxV2Store.delete([data.worldEpoch,`state:${id}`]);outboxV2Store.put(row)}
|
|||
|
|
for(const id of deleted)if(!committedDeleted.has(id))outboxV2Store.delete([data.worldEpoch,`deleted:${id}`]);
|
|||
|
|
if(globalChanged)outboxV2Store.put({epoch:data.worldEpoch,key:'global',type:'global'});
|
|||
|
|
}
|
|||
|
|
const nextWorld={...storedWorldRow,epoch:data.worldEpoch,status:'active',schema:SAVE_SCHEMA,worldGeneration:WORLD_GENERATION,global:committedGlobal,boardCount:Object.keys(data.metas).length,solvedCount:data.solved||0,score:data.score||0,bounds:fieldBoundsFromMetas(data.metas),createdAt:storedWorldRow.createdAt||updatedAt,activatedAt:storedWorldRow.activatedAt||updatedAt,progress:null};
|
|||
|
|
worldsStore.put(nextWorld);
|
|||
|
|
if(currentRecoverySeq>=0){const key=`covered:${sessionId}`,value={seq:Math.min(currentRecoverySeq,globalSeq),updatedAt,worldEpoch:data.worldEpoch};recoveryV2Store.put({epoch:data.worldEpoch,key,value});recoveryV2Store.delete([data.worldEpoch,`journal:${sessionId}`])}
|
|||
|
|
for(const journal of coveredJournals)if(journal.sessionId&&Number.isSafeInteger(journal.seq)){const key=`covered:${journal.sessionId}`,value={seq:journal.seq,updatedAt,worldEpoch:data.worldEpoch};recoveryV2Store.put({epoch:data.worldEpoch,key,value});if(journal._dbKey)recoveryV2Store.delete([data.worldEpoch,journal._dbKey])}
|
|||
|
|
await done;
|
|||
|
|
updateStorageRevision({databaseUpdatedAt:updatedAt});
|
|||
|
|
scheduleMirrorCheckpoint();
|
|||
|
|
}
|
|||
|
|
for(const[id,version]of metaVersions)if(compareRevisionVersions(data.metas[id],version)===0){
|
|||
|
|
const committed=committedMetas.has(id)?committedMetas.get(id):data.metas[id];
|
|||
|
|
if(committed){if(compareRevisionVersions(committed,version)!==0)reconciledPersistenceConflict=true;data.metas[id]=normalizeMeta(id,committed)||data.metas[id]}
|
|||
|
|
else{reconciledPersistenceConflict=true;delete data.metas[id];delete data.states[id]}
|
|||
|
|
dirtyMetaIds.delete(id);
|
|||
|
|
}
|
|||
|
|
for(const[id,version]of stateVersions)if(compareRevisionVersions(data.states[id],version)===0){
|
|||
|
|
const committed=committedStates.has(id)?committedStates.get(id):data.states[id];
|
|||
|
|
if(committed&&data.metas[id]){if(compareRevisionVersions(committed,version)!==0)reconciledPersistenceConflict=true;data.states[id]=normalizeState(committed);normalizedStateObjects.add(data.states[id])}
|
|||
|
|
else{reconciledPersistenceConflict=true;delete data.states[id]}
|
|||
|
|
dirtyStateIds.delete(id);
|
|||
|
|
}
|
|||
|
|
for(const id of deleted){
|
|||
|
|
const version=deletedVersions.get(id);
|
|||
|
|
if(deletedBoardRevisions.get(id)!==version.rev||deletedBoardAuthors.get(id)!==version.revAuthor)continue;
|
|||
|
|
if(!committedDeleted.has(id)){
|
|||
|
|
const committedMeta=committedMetas.get(id),committedState=committedStates.get(id);
|
|||
|
|
if(committedMeta){
|
|||
|
|
data.metas[id]=normalizeMeta(id,committedMeta)||committedMeta;
|
|||
|
|
if(committedState){data.states[id]=normalizeState(committedState);normalizedStateObjects.add(data.states[id])}
|
|||
|
|
reconciledPersistenceConflict=true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
deletedBoardIds.delete(id);deletedBoardRevisions.delete(id);deletedBoardAuthors.delete(id);
|
|||
|
|
}
|
|||
|
|
for(const id of deleted)if(!committedDeleted.has(id))cloudJournalDeletedIds.delete(id);
|
|||
|
|
applyGlobalRecordToData(committedGlobal);data.updatedAt=Math.max(data.updatedAt||0,updatedAt);data.clockFloor=Math.max(data.clockFloor||0,updatedAt);
|
|||
|
|
if(activeStorageFormat===FIELD_STORAGE_FORMAT)for(const id of new Set([...metaIds,...stateIds,...deleted])){
|
|||
|
|
if(data.metas[id]&&data.states[id])boardIndexSummaries.set(id,summarizeBoardV2(data.metas[id],data.states[id],data.worldEpoch));else boardIndexSummaries.delete(id);
|
|||
|
|
}
|
|||
|
|
if(reconciledPersistenceConflict)refreshWorldView({rebuild:true,syncConnections:false,persist:false});
|
|||
|
|
for(const key of outboxDeletes)cloudOutboxDeleteKeys.delete(key);
|
|||
|
|
if(globalChangeSeq===globalSeq)globalDirty=false;
|
|||
|
|
clearRecoveryJournalIfCovered(globalSeq,coveredJournals);
|
|||
|
|
const signal={commitId:`${sessionId}:${++worldSignalSeq}`,updatedAt,sessionId,worldEpoch:data.worldEpoch,metaIds,stateIds,deleted:[...committedDeleted],globalChanged};broadcastWorldSignal(signal);if(!skipCloud)scheduleCloudPush(signal);
|
|||
|
|
perfGauge('lastPersistRows',metaRows.length+stateRows.length+deleted.length);perfEnd('persistDirtyToDb',started);
|
|||
|
|
return{updatedAt,count:metaRows.length+stateRows.length+deleted.length,skipped:false};
|
|||
|
|
}
|
|||
|
|
async function persistNow(options={}){
|
|||
|
|
if(lifecyclePersistenceSuppressed)return false;
|
|||
|
|
if(options.lifecycle!==true&&globalThis.document?.body?.classList?.contains?.('is-interacting')){perfCount('savesDeferredDuringInteraction');await waitForInteractionSettle(null)}
|
|||
|
|
clearTimeout(saveTimer);saveTimer=null;
|
|||
|
|
const run=async()=>{if(lifecyclePersistenceSuppressed)return false;try{
|
|||
|
|
const result=await(options.lockHeld===true?persistDirtyToDb(options):withWorldMutationLock(()=>persistDirtyToDb(options)));
|
|||
|
|
if(!result.skipped){setSaveStatus('saved','\u4fdd\u5b58\u3057\u307e\u3057\u305f');hideStatus();if(result.count>0&&startupWorldControl?.activationVerified===false){try{await verifyActiveWorldActivation()}catch(error){const restored=await rollbackUnverifiedWorld(await openWorldDb(),startupWorldControl).catch(()=>null);if(restored){lifecyclePersistenceSuppressed=true;rememberWorldEpoch(restored.activeEpoch);announceWorldReplacement(restored.activeEpoch);location.reload();return false}throw error}}}
|
|||
|
|
if(hasPendingPersistence()){clearTimeout(saveTimer);saveTimer=setTimeout(runDeferredSave,SAVE_DELAY)}
|
|||
|
|
return true;
|
|||
|
|
}catch(error){
|
|||
|
|
if(error?.code==='STALE_WORLD_EPOCH'){lifecyclePersistenceSuppressed=true;setSaveStatus('error','\u5225\u306e\u30bf\u30d6\u3067\u66f4\u65b0\u3055\u308c\u307e\u3057\u305f');showStatus(error.message,{retry:false,fresh:false});setTimeout(()=>location.reload(),80);return false}
|
|||
|
|
setSaveStatus('error','\u4fdd\u5b58\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002');showStatus('\u4fdd\u5b58\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002',{retry:true,fresh:false});console.warn('BEND FIELD: save failed',error);return false}};
|
|||
|
|
if(options.lockHeld===true)return run();
|
|||
|
|
const result=persistQueue.then(run,run);persistQueue=result.then(()=>{},()=>{});return result;
|
|||
|
|
}
|
|||
|
|
function flushSave(options={}){return persistNow(options)}
|
|||
|
|
function runDeferredSave(){
|
|||
|
|
if(document.body?.classList?.contains('is-interacting')){clearTimeout(saveTimer);saveTimer=setTimeout(runDeferredSave,SAVE_DELAY);perfCount('savesDeferredDuringInteraction');return}
|
|||
|
|
void persistNow();
|
|||
|
|
}
|
|||
|
|
function save(immediate=false,options={}){
|
|||
|
|
if(lifecyclePersistenceSuppressed)return immediate?Promise.resolve(false):false;
|
|||
|
|
if(!hasPendingPersistence())return immediate?Promise.resolve(true):true;
|
|||
|
|
if(immediate)return persistNow(options);
|
|||
|
|
setSaveStatus('saving','\u4fdd\u5b58\u4e2d');clearTimeout(saveTimer);saveTimer=setTimeout(runDeferredSave,SAVE_DELAY);return true;
|
|||
|
|
}
|
|||
|
|
function updatePlayerNameUi(){if(!playerNameBtn)return;const name=currentPlayerName();playerNameBtn.textContent=name;playerNameBtn.title=`プレイヤー名を変更:${name}`}
|
|||
|
|
function updateHud(){pruneAndCount();worldCountEl.textContent=Object.keys(data.metas).length;updatePlayerNameUi();applyUiSettings();syncCursorAppearance(data.cursorStyle||'default');document.body.dataset.debugItems=data.debugAllItems?'on':'off';updateInventoryUi();updateTimeAttackUi()}
|
|||
|
|
function changed(id,pathIndex=null){const st=metaState(id);st.rev=nextRevision();st.revAuthor=sessionId;markStateDirty(id,pathIndex);save()}
|
|||
|
|
const SHAPES_BY_SIZE=new Map();for(const s of SHAPES){const n=s.length;if(!SHAPES_BY_SIZE.has(n))SHAPES_BY_SIZE.set(n,[]);SHAPES_BY_SIZE.get(n).push(s)}
|
|||
|
|
function shapeCandidatesForLevel(seed,level,attemptsPerSize=12){return AppLogic.shapeCandidatesForLevel(seed,level,attemptsPerSize,{shapesBySize:SHAPES_BY_SIZE,hash32,rngFrom,shuffle})}
|
|||
|
|
function gateConnectionAllowed(meta,gateIndex){
|
|||
|
|
if(!meta?.puzzle||!Number.isInteger(gateIndex))return false;
|
|||
|
|
const gate=puzzleOf(meta).g[gateIndex];return Boolean(gate&&!(meta.sealedSides||[]).includes(gate[2]));
|
|||
|
|
}
|
|||
|
|
function boardMeta(id){return data.metas[id]}
|
|||
|
|
function puzzleOf(meta){
|
|||
|
|
if(!meta?.puzzle)throw new Error(`\u76e4\u9762${meta?.id||'?'}\u306e\u8aad\u307f\u8fbc\u307f\u304c\u5b8c\u4e86\u3057\u3066\u3044\u307e\u305b\u3093\u3002`);
|
|||
|
|
if(meta.puzzle.difficulty==null)
|
|||
|
|
meta.puzzle.difficulty=solverDifficulty(meta.puzzle,meta.targetLevel??meta.level??1);
|
|||
|
|
if(meta.level!==meta.puzzle.difficulty){meta.level=meta.puzzle.difficulty;markMetaDirty(meta.id)}
|
|||
|
|
return meta.puzzle
|
|||
|
|
}
|
|||
|
|
function validateHydratedMeta(meta){
|
|||
|
|
if(!meta)throw new Error('\u76e4\u9762\u60c5\u5831\u304c\u3042\u308a\u307e\u305b\u3093\u3002');
|
|||
|
|
if(!meta.puzzle)throw new Error(`\u76e4\u9762${meta.id}\u306e\u30c7\u30fc\u30bf\u304c\u3042\u308a\u307e\u305b\u3093\u3002`);
|
|||
|
|
puzzleOf(meta);sanitizeStateForPuzzle(meta);
|
|||
|
|
if(data.quarantine)delete data.quarantine[meta.id];
|
|||
|
|
return meta;
|
|||
|
|
}
|
|||
|
|
function rememberHydratedBoard(id,bytes){
|
|||
|
|
const previous=hydratedBoardLru.get(id);if(previous)hydratedBoardBytes-=previous.bytes;
|
|||
|
|
hydratedBoardLru.delete(id);hydratedBoardLru.set(id,{bytes});hydratedBoardBytes+=bytes;evictHydratedBoardDetails();
|
|||
|
|
}
|
|||
|
|
function hydratedBoardPinned(id){return id===activeBoard||dirtyMetaIds.has(id)||dirtyStateIds.has(id)||rendered.has(id)||staticRendered.has(id)||pendingVisibleHydrations?.has?.(id)||openStoreBoardId===id}
|
|||
|
|
function evictHydratedBoardDetails(){
|
|||
|
|
let attempts=0;
|
|||
|
|
while((hydratedBoardLru.size>32||hydratedBoardBytes>8*1024*1024)&&attempts<hydratedBoardLru.size){
|
|||
|
|
const[id,entry]=hydratedBoardLru.entries().next().value;
|
|||
|
|
if(hydratedBoardPinned(id)){hydratedBoardLru.delete(id);hydratedBoardLru.set(id,entry);attempts++;continue}
|
|||
|
|
hydratedBoardLru.delete(id);hydratedBoardBytes-=entry.bytes;const target=data.metas[id],summary=boardIndexSummaries.get(id);
|
|||
|
|
if(target&&summary){target.puzzle=null;target._summaryOnly=true;data.states[id]=summaryStateFromIndex(summary);normalizedStateObjects.add(data.states[id]);perfCount('boardDetailEvictions')}
|
|||
|
|
attempts=0;
|
|||
|
|
}
|
|||
|
|
perfGauge('hydratedBoards',hydratedBoardLru.size);perfGauge('hydratedBoardBytes',hydratedBoardBytes);
|
|||
|
|
}
|
|||
|
|
async function hydrateMeta(meta){
|
|||
|
|
if(!meta)throw new Error('Board metadata is missing.');
|
|||
|
|
if(!meta.puzzle){
|
|||
|
|
if(activeStorageFormat!==FIELD_STORAGE_FORMAT||!idbAvailable)throw new Error(`Board details are missing: ${meta.id}`);
|
|||
|
|
const db=await openWorldDb(),tx=db.transaction(['control','boardIndex','boardPuzzles','boardStates'],'readonly'),done=transactionDone(tx),key=[data.worldEpoch,meta.id],
|
|||
|
|
[control,index,puzzle,state]=await Promise.all([requestValue(tx.objectStore('control').get('active')),requestValue(tx.objectStore('boardIndex').get(key)),requestValue(tx.objectStore('boardPuzzles').get(key)),requestValue(tx.objectStore('boardStates').get(key))]);await done;
|
|||
|
|
if(!control||control.activeEpoch!==data.worldEpoch||control.activeFormat!==FIELD_STORAGE_FORMAT)throw staleWorldEpochError();
|
|||
|
|
if(!index||!puzzle||!state||index.metaRev!==puzzle.metaRev||index.stateRev!==state.stateRev)throw new Error(`Board detail revisions do not match: ${meta.id}`);
|
|||
|
|
const loadedMeta=normalizeMeta(meta.id,{id:meta.id,x:index.x,y:index.y,chunks:index.chunks,level:index.level,targetLevel:index.targetLevel,seed:index.seed,axis:index.axis,entrySide:index.entrySide||null,sealedSides:index.sealedSides||[],puzzle:puzzle.puzzle,generatorVersion:puzzle.generatorVersion||GENERATOR_VERSION,rev:puzzle.metaRev,revAuthor:puzzle.revAuthor||''});
|
|||
|
|
if(!loadedMeta)throw new Error(`Stored puzzle is invalid: ${meta.id}`);
|
|||
|
|
const loadedState=normalizeState(state.value);if(loadedState.rev!==index.stateRev)throw new Error(`Board state revision does not match: ${meta.id}`);
|
|||
|
|
boardIndexSummaries.set(meta.id,index);Object.assign(meta,loadedMeta);delete meta._summaryOnly;data.states[meta.id]=loadedState;normalizedStateObjects.add(loadedState);rememberStateSignatures(meta.id,loadedState);
|
|||
|
|
rememberHydratedBoard(meta.id,JSON.stringify(puzzle).length+JSON.stringify(state).length);perfCount('boardDetailHydrations');
|
|||
|
|
}else if(hydratedBoardLru.has(meta.id))rememberHydratedBoard(meta.id,hydratedBoardLru.get(meta.id).bytes);
|
|||
|
|
return validateHydratedMeta(meta);
|
|||
|
|
}
|
|||
|
|
function createMeta(x,y,chunks,level,seed,id=null,puzzle=null){
|
|||
|
|
id=id||'B'+data.nextId++;
|
|||
|
|
if(!puzzle)throw new Error('\u76e4\u9762\u30c7\u30fc\u30bf\u304c\u3042\u308a\u307e\u305b\u3093\u3002');
|
|||
|
|
const p=puzzle;
|
|||
|
|
const meta={id,x,y,chunks,level:p.difficulty,targetLevel:level,seed,axis:p.axis,entrySide:null,sealedSides:[],puzzle:p,generatorVersion:GENERATOR_VERSION,rev:nextRevision()};
|
|||
|
|
data.metas[id]=meta;markMetaDirty(id);metaState(id);markStateDirty(id);rebuildOccupancy();
|
|||
|
|
return meta
|
|||
|
|
}
|
|||
|
|
async function ensureStart(){
|
|||
|
|
if(Object.keys(data.metas).length)return;
|
|||
|
|
const chunks=[[0,0]],puzzle=normalizeStoredPuzzle(deepClone(STARTER_PUZZLE),chunks,1);
|
|||
|
|
if(!puzzle)throw new Error('\u539f\u70b9\u306e\u76e4\u9762\u30c7\u30fc\u30bf\u304c\u58ca\u308c\u3066\u3044\u307e\u3059\u3002');
|
|||
|
|
createMeta(0,0,chunks,1,STARTER_SEED,'B0',puzzle);
|
|||
|
|
data.nextId=Math.max(data.nextId,1);
|
|||
|
|
}
|
|||
|
|
function globalCell(meta,cell){return[meta.y*5+cell[0],meta.x*5+cell[1]]}
|
|||
|
|
function metaAtGlobalCell(r,c){const id=occupancy.get(key2(Math.floor(c/5),Math.floor(r/5)));return id?boardMeta(id)||null:null}
|
|||
|
|
function unitOccupiedAtGlobalCell(r,c,exceptId=null){const meta=metaAtGlobalCell(r,c);return!!(meta&&meta.id!==exceptId)}
|
|||
|
|
async function hydrateAdjacentMetas(meta){
|
|||
|
|
if(!meta?.puzzle)return;
|
|||
|
|
const ids=new Set(),p=puzzleOf(meta);
|
|||
|
|
for(const g of p.g){const[gr,gc]=globalCell(meta,[g[0],g[1]]),[dr,dc]=SIDE_D[g[2]],neighbor=metaAtGlobalCell(gr+dr,gc+dc);if(neighbor&&neighbor.id!==meta.id&&!neighbor.puzzle)ids.add(neighbor.id)}
|
|||
|
|
for(const id of ids){try{await hydrateMeta(data.metas[id])}catch(error){console.warn(`BEND FIELD: adjacent hydration deferred for ${id}`,error)}}
|
|||
|
|
adjacencyCache.clear();
|
|||
|
|
}
|
|||
|
|
function matchingNeighborGate(meta,gi){const cacheKey=meta.id+':'+gi;if(adjacencyCache.has(cacheKey))return adjacencyCache.get(cacheKey);if(!meta?.puzzle||!gateConnectionAllowed(meta,gi)){adjacencyCache.set(cacheKey,null);return null}const p=puzzleOf(meta),g=p.g[gi],[gr,gc]=globalCell(meta,[g[0],g[1]]),[dr,dc]=SIDE_D[g[2]],nr=gr+dr,nc=gc+dc,nb=metaAtGlobalCell(nr,nc);if(!nb||nb.id===meta.id||!nb.puzzle){adjacencyCache.set(cacheKey,null);return null}const np=puzzleOf(nb),lr=nr-nb.y*5,lc=nc-nb.x*5;if(!cellSet(np).has(ckey(lr,lc))){adjacencyCache.set(cacheKey,null);return null}const ngi=gateAtCell(np,[lr,lc],null,OPP[g[2]]);const hit=ngi==null||!gateConnectionAllowed(nb,ngi)?null:{meta:nb,gateIndex:ngi};adjacencyCache.set(cacheKey,hit);return hit}
|
|||
|
|
function pathUsesGate(path,gi){return Boolean(path&&((!path.detachedStart&&path.startGate===gi)||path.endGate===gi||path.openGate===gi))}
|
|||
|
|
function gateUsedPath(st,gi){return st.paths.find(path=>path&&Array.isArray(path.cells)&&pathUsesGate(path,gi))||null}
|
|||
|
|
function pathColorIndexAtGate(path,gi){
|
|||
|
|
if(!path)return null;
|
|||
|
|
if(!path.detachedStart&&path.startGate===gi)return path.startColorIndex??path.colorIndex??0;
|
|||
|
|
if(path.endGate===gi||path.openGate===gi)return path.endColorIndex??path.colorIndex??path.startColorIndex??0;
|
|||
|
|
return path.colorIndex??0;
|
|||
|
|
}
|
|||
|
|
function setPathColorAtGate(path,gi,colorIndex){
|
|||
|
|
if(!path||colorIndex==null)return false;
|
|||
|
|
const key=!path.detachedStart&&path.startGate===gi?'startColorIndex':path.endGate===gi||path.openGate===gi?'endColorIndex':null;
|
|||
|
|
if(!key||path[key]===colorIndex)return false;
|
|||
|
|
path[key]=colorIndex;
|
|||
|
|
if(key==='startColorIndex')path.colorIndex=colorIndex;
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
function normalizeLineColorIndex(value){return Number.isInteger(value)?((value%LINE_COLORS.length)+LINE_COLORS.length)%LINE_COLORS.length:null}
|
|||
|
|
function boundaryColorSource(meta,gi,hit=matchingNeighborGate(meta,gi)){
|
|||
|
|
if(!hit)return null;
|
|||
|
|
const st=metaState(meta.id),neighborState=metaState(hit.meta.id),own=gateUsedPath(st,gi),other=gateUsedPath(neighborState,hit.gateIndex),candidates=[];
|
|||
|
|
if(own)candidates.push({meta,gateIndex:gi,state:st,path:own,colorIndex:normalizeLineColorIndex(pathColorIndexAtGate(own,gi))});
|
|||
|
|
if(other)candidates.push({meta:hit.meta,gateIndex:hit.gateIndex,state:neighborState,path:other,colorIndex:normalizeLineColorIndex(pathColorIndexAtGate(other,hit.gateIndex))});
|
|||
|
|
const usable=candidates.filter(candidate=>candidate.colorIndex!=null);if(!usable.length)return null;
|
|||
|
|
usable.sort((a,b)=>Number(b.state.solved)-Number(a.state.solved)||(a.state.rev||0)-(b.state.rev||0)||String(a.meta.id).localeCompare(String(b.meta.id))||a.gateIndex-b.gateIndex);
|
|||
|
|
return usable[0];
|
|||
|
|
}
|
|||
|
|
function confirmedBoundaryColorIndex(meta,gi){
|
|||
|
|
const hit=matchingNeighborGate(meta,gi);if(!hit)return null;
|
|||
|
|
const st=metaState(meta.id),neighborState=metaState(hit.meta.id),own=gateUsedPath(st,gi),other=gateUsedPath(neighborState,hit.gateIndex);
|
|||
|
|
if(!own&&!other)return null;
|
|||
|
|
if(!st.solved&&!neighborState.solved)return null;
|
|||
|
|
return boundaryColorSource(meta,gi,hit)?.colorIndex??null;
|
|||
|
|
}
|
|||
|
|
function sharedBoundaryColorIndex(meta,gi){const hit=matchingNeighborGate(meta,gi);return hit?boundaryColorSource(meta,gi,hit)?.colorIndex??null:null}
|
|||
|
|
function displayedEndpointColorIndex(meta,path,side='end'){if(!path)return 0;let gateIndex=null;if(side==='start'&&!path.detachedStart)gateIndex=path.startGate;else if(side==='end'){if(Number.isInteger(path.openGate))gateIndex=path.openGate;else if(Number.isInteger(path.endGate))gateIndex=path.endGate;else if(path.cells?.length===1&&!path.detachedStart)gateIndex=path.startGate}const shared=Number.isInteger(gateIndex)?sharedBoundaryColorIndex(meta,gateIndex):null;if(shared!=null)return shared;return side==='start'?(path.startColorIndex??path.colorIndex??0):(path.endColorIndex??path.startColorIndex??path.colorIndex??0)}
|
|||
|
|
function neighborColor(meta,gi){return sharedBoundaryColorIndex(meta,gi)}
|
|||
|
|
function addInheritedBoundaryPath(meta,gi,colorIndex){
|
|||
|
|
const st=metaState(meta.id);if(st.solved||gateUsedPath(st,gi))return false;
|
|||
|
|
const p=puzzleOf(meta),g=gateObj(p,gi),occ=occupiedMap(st);if(occ.has(ckey(...g.cell)))return false;
|
|||
|
|
st.paths.push({startGate:gi,endGate:null,openGate:null,cells:[[g.cell[0],g.cell[1]]],colorIndex,startColorIndex:colorIndex,endColorIndex:null});return true;
|
|||
|
|
}
|
|||
|
|
function syncBoundaryConnections({rebuild=true,persist=true,updateProgress=true}={}){
|
|||
|
|
const touched=new Set(),processed=new Set();
|
|||
|
|
if(rebuild)rebuildOccupancy();
|
|||
|
|
const candidateIds=new Set();
|
|||
|
|
if(!rebuild&&dirtyStateIds.size){
|
|||
|
|
for(const id of dirtyStateIds){
|
|||
|
|
const meta=data.metas[id];if(!meta)continue;candidateIds.add(id);
|
|||
|
|
for(const[dx,dy]of meta.chunks||[]){const unitX=meta.x+dx,unitY=meta.y+dy;for(const[dr,dc]of Object.values(SIDE_D)){const neighborId=occupancy.get(key2(unitX+dc,unitY+dr));if(neighborId)candidateIds.add(neighborId)}}
|
|||
|
|
}
|
|||
|
|
}else for(const id of Object.keys(data.metas))candidateIds.add(id);
|
|||
|
|
for(const id of candidateIds){
|
|||
|
|
const meta=data.metas[id];
|
|||
|
|
if(!meta?.puzzle)continue;
|
|||
|
|
const st=metaState(meta.id),p=puzzleOf(meta);
|
|||
|
|
for(let gi=0;gi<p.g.length;gi++){
|
|||
|
|
const hit=matchingNeighborGate(meta,gi);if(!hit)continue;
|
|||
|
|
const ends=[`${meta.id}:${gi}`,`${hit.meta.id}:${hit.gateIndex}`].sort(),pairKey=ends.join('|');if(processed.has(pairKey))continue;processed.add(pairKey);
|
|||
|
|
const nst=metaState(hit.meta.id),own=gateUsedPath(st,gi),other=gateUsedPath(nst,hit.gateIndex);
|
|||
|
|
if(!own&&!other)continue;
|
|||
|
|
const source=boundaryColorSource(meta,gi,hit);if(!source)continue;const colorIndex=source.colorIndex;
|
|||
|
|
if(own&&setPathColorAtGate(own,gi,colorIndex))touched.add(meta.id);
|
|||
|
|
if(other&&setPathColorAtGate(other,hit.gateIndex,colorIndex))touched.add(hit.meta.id);
|
|||
|
|
if(!own&&nst.solved&&other&&addInheritedBoundaryPath(meta,gi,colorIndex))touched.add(meta.id);
|
|||
|
|
if(!other&&st.solved&&own&&addInheritedBoundaryPath(hit.meta,hit.gateIndex,colorIndex))touched.add(hit.meta.id);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if(touched.size){
|
|||
|
|
const now=Date.now();let k=0;
|
|||
|
|
for(const id of touched){metaState(id).rev=Math.max(nextRevision(),now+(++k));markStateDirty(id)}
|
|||
|
|
if(persist)save();
|
|||
|
|
}
|
|||
|
|
if(updateProgress)updateSelectedProgress(rendered.get(activeBoard));
|
|||
|
|
return touched.size;
|
|||
|
|
}
|
|||
|
|
function refreshWorldView(options={}){
|
|||
|
|
if(globalThis.document?.body?.classList?.contains?.('is-interacting'))return waitForInteractionSettle().then(()=>refreshWorldView(options));
|
|||
|
|
const{rebuild=true,syncConnections=true,markStats=false,resumeTimer=false,hide=false,persist=false,immediate=false,lockHeld=false}=options;
|
|||
|
|
if(rebuild)rebuildOccupancy();
|
|||
|
|
if(syncConnections)syncBoundaryConnections({rebuild:false,persist:false,updateProgress:false});
|
|||
|
|
if(markStats)statsDirty=true;
|
|||
|
|
ensureBoards();renderAll();updateHud();updateSelectedProgress(rendered.get(activeBoard));
|
|||
|
|
if(resumeTimer)resumeTimeAttackTimer();if(hide)hideStatus();
|
|||
|
|
return persist?save(immediate,{lockHeld}):true;
|
|||
|
|
}
|
|||
|
|
function waitForInteractionSettle(counter='worldRefreshesDeferredDuringInteraction'){
|
|||
|
|
if(!globalThis.document?.body?.classList?.contains?.('is-interacting'))return Promise.resolve();
|
|||
|
|
if(counter)perfCount(counter);
|
|||
|
|
return new Promise(resolve=>{const check=()=>globalThis.document?.body?.classList?.contains?.('is-interacting')?setTimeout(check,40):resolve();check()});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function fitsMeta(x,y,chunks){
|
|||
|
|
for(const[dx,dy]of chunks)if(occupancy.has(key2(x+dx,y+dy)))return false;
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
function placedShapeKeys(x,y,chunks){return new Set(chunks.map(([dx,dy])=>key2(x+dx,y+dy)))}
|
|||
|
|
function unitOccupiedWithExtra(unitX,unitY,extraOccupied=null){const key=key2(unitX,unitY);return occupancy.has(key)||extraOccupied?.has(key)}
|
|||
|
|
function isClosedVoidWithExtra(unitX,unitY,extraOccupied=null){
|
|||
|
|
if(unitOccupiedWithExtra(unitX,unitY,extraOccupied))return false;
|
|||
|
|
let count=0;for(const[dr,dc]of Object.values(SIDE_D))if(unitOccupiedWithExtra(unitX+dc,unitY+dr,extraOccupied))count++;
|
|||
|
|
return count===4;
|
|||
|
|
}
|
|||
|
|
function openUnitFrontiers(meta,extraOccupied=null){
|
|||
|
|
if(!meta?.chunks?.length)return[];
|
|||
|
|
const own=new Set(meta.chunks.map(([dx,dy])=>key2(meta.x+dx,meta.y+dy))),frontiers=new Map();
|
|||
|
|
for(const[dx,dy]of meta.chunks){
|
|||
|
|
const unitX=meta.x+dx,unitY=meta.y+dy;
|
|||
|
|
for(const side of['N','S','W','E']){
|
|||
|
|
const[dr,dc]=SIDE_D[side],targetX=unitX+dc,targetY=unitY+dr,key=key2(targetX,targetY);
|
|||
|
|
if(own.has(key)||unitOccupiedWithExtra(targetX,targetY,extraOccupied)||isClosedVoidWithExtra(targetX,targetY,extraOccupied))continue;
|
|||
|
|
if(!frontiers.has(key))frontiers.set(key,{unitX:targetX,unitY:targetY,side,targetSide:OPP[side]});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return[...frontiers.values()];
|
|||
|
|
}
|
|||
|
|
function shapeFitsWithExtra(x,y,chunks,extraOccupied=null){
|
|||
|
|
for(const[dx,dy]of chunks){const key=key2(x+dx,y+dy);if(occupancy.has(key)||extraOccupied?.has(key))return false}
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
function viableFuturePlacementAt(unitX,unitY,extraOccupied=null){
|
|||
|
|
if(unitOccupiedWithExtra(unitX,unitY,extraOccupied)||isClosedVoidWithExtra(unitX,unitY,extraOccupied))return false;
|
|||
|
|
const targetLevel=macroDifficulty(unitX,unitY),
|
|||
|
|
seed=hash32(Math.imul(unitX+0x3191,73856093)^Math.imul(unitY-0x47b5,19349663)^0x51ed270b),
|
|||
|
|
candidates=shapeCandidatesForLevel(seed,targetLevel,5);
|
|||
|
|
for(const shape of candidates)for(const[dx,dy]of shape)if(shapeFitsWithExtra(unitX-dx,unitY-dy,shape,extraOccupied))return true;
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
function viableExpansionExists(meta,extraOccupied=null){
|
|||
|
|
for(const frontier of openUnitFrontiers(meta,extraOccupied))if(viableFuturePlacementAt(frontier.unitX,frontier.unitY,extraOccupied))return true;
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
function prospectiveShapeHasFrontier(x,y,chunks){
|
|||
|
|
const own=placedShapeKeys(x,y,chunks),prospective={x,y,chunks};
|
|||
|
|
return viableExpansionExists(prospective,own);
|
|||
|
|
}
|
|||
|
|
function placementPreservesUnsolvedFrontiers(x,y,chunks,sourceId=null,{allowTerminal=false}={}){
|
|||
|
|
const extra=placedShapeKeys(x,y,chunks);
|
|||
|
|
const terminalFill=allowTerminal&&chunks.length===1&&isClosedVoidUnit(x,y),
|
|||
|
|
connectedMetaIds=terminalFill?new Set(placementConnectionRequirements(x,y,chunks).map(requirement=>requirement.metaId)):null;
|
|||
|
|
if(!terminalFill&&!prospectiveShapeHasFrontier(x,y,chunks))return false;
|
|||
|
|
const affectedIds=new Set();
|
|||
|
|
for(const key of extra){const[unitX,unitY]=key.split(',').map(Number);for(const[dr,dc]of Object.values(SIDE_D)){const id=occupancy.get(key2(unitX+dc,unitY+dr));if(id)affectedIds.add(id)}}
|
|||
|
|
for(const id of affectedIds){
|
|||
|
|
const meta=data.metas[id];
|
|||
|
|
if(!meta?.chunks?.length||meta.id===sourceId)continue;const state=metaState(meta.id);
|
|||
|
|
if(state.solved&&state.expanded)continue;
|
|||
|
|
const viableBefore=viableExpansionExists(meta);
|
|||
|
|
if(viableBefore&&!viableExpansionExists(meta,extra)&&!connectedMetaIds?.has(meta.id))return false;
|
|||
|
|
}
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
function storeWorldCenter(meta){
|
|||
|
|
const count=Math.max(1,meta.chunks.length),
|
|||
|
|
x=meta.x+.5+meta.chunks.reduce((sum,chunk)=>sum+chunk[0],0)/count,
|
|||
|
|
y=meta.y+.5+meta.chunks.reduce((sum,chunk)=>sum+chunk[1],0)/count;
|
|||
|
|
return[x,y];
|
|||
|
|
}
|
|||
|
|
function worldSeedValue(){return data.metas.B0?.seed??STARTER_SEED}
|
|||
|
|
function storePriceLocation(meta,store){
|
|||
|
|
const path=store&&metaState(meta.id).paths[store.pathIndex],cell=path?.cells?.[store.cellIndex];
|
|||
|
|
return cell?[meta.x+(cell[1]+.5)/CHUNK,meta.y+(cell[0]+.5)/CHUNK]:storeWorldCenter(meta);
|
|||
|
|
}
|
|||
|
|
function storeCellForMeta(meta){
|
|||
|
|
const state=meta&&metaState(meta.id),store=state?.store,path=store&&state.paths[store.pathIndex],cell=path?.cells?.[store.cellIndex];
|
|||
|
|
return Array.isArray(cell)?cell:Array.isArray(store?.summaryCell)?store.summaryCell:null;
|
|||
|
|
}
|
|||
|
|
function storePriceDetails(meta,store){
|
|||
|
|
const[x,y]=storePriceLocation(meta,store),priceVersion=store?.priceVersion||STORE_PRICE_VERSION,
|
|||
|
|
computed=AppLogic.deterministicStorePrice(1,worldSeedValue(),x,y,priceVersion),
|
|||
|
|
coefficient=Number.isFinite(store?.priceCoefficient)?store.priceCoefficient:computed.coefficient;
|
|||
|
|
return{coefficient,priceVersion,x,y};
|
|||
|
|
}
|
|||
|
|
function storeItemPrice(meta,store,item){
|
|||
|
|
const coefficient=storePriceDetails(meta,store).coefficient,baseCost=Math.max(0,Number(item?.cost)||0);
|
|||
|
|
const adjusted=Math.round(baseCost*coefficient);
|
|||
|
|
if(item?.id?.startsWith('cursor-face-'))return Math.max(MIN_CURSOR_PRICE,Math.min(MAX_FACE_CURSOR_PRICE,adjusted));
|
|||
|
|
return Math.max(item?.cursorStyle?MIN_CURSOR_PRICE:MIN_STORE_ITEM_PRICE,adjusted);
|
|||
|
|
}
|
|||
|
|
let storeEffectSourcesCache=null,storeEffectSpatialCache=null;
|
|||
|
|
function targetLevelForNewBoard(x,y,chunks){
|
|||
|
|
const count=Math.max(1,chunks.length),centerX=x+.5+chunks.reduce((sum,chunk)=>sum+chunk[0],0)/count,centerY=y+.5+chunks.reduce((sum,chunk)=>sum+chunk[1],0)/count;
|
|||
|
|
return macroDifficulty(centerX,centerY);
|
|||
|
|
}
|
|||
|
|
let worldMutationQueue=Promise.resolve();
|
|||
|
|
function sleep(ms){return new Promise(resolve=>setTimeout(resolve,ms))}
|
|||
|
|
async function withStorageLease(name,task){
|
|||
|
|
const key=`${name}:lease`,token=`${sessionId}:${Math.random().toString(36).slice(2)}`,deadline=Date.now()+20000;
|
|||
|
|
if(storageAccessError)return task();
|
|||
|
|
while(Date.now()<deadline){
|
|||
|
|
const now=Date.now();let existing=null;try{existing=JSON.parse(safeLocalGet(key)||'null')}catch(_){}
|
|||
|
|
if(!existing||existing.expiresAt<now||existing.token===token){safeLocalSet(key,JSON.stringify({token,expiresAt:now+15000}));let check=null;try{check=JSON.parse(safeLocalGet(key)||'null')}catch(_){}if(check?.token===token){
|
|||
|
|
const renew=setInterval(()=>{let current=null;try{current=JSON.parse(safeLocalGet(key)||'null')}catch(_){}if(current?.token===token)safeLocalSet(key,JSON.stringify({token,expiresAt:Date.now()+15000}))},5000);
|
|||
|
|
try{return await task()}finally{clearInterval(renew);let current=null;try{current=JSON.parse(safeLocalGet(key)||'null')}catch(_){}if(current?.token===token)safeLocalRemove(key)}
|
|||
|
|
}}
|
|||
|
|
await sleep(35+Math.random()*90);
|
|||
|
|
}
|
|||
|
|
throw new Error('\u5225\u306e\u30bf\u30d6\u3067\u66f4\u65b0\u3057\u3066\u3044\u307e\u3059\u3002');
|
|||
|
|
}
|
|||
|
|
function withWorldMutationLock(task){
|
|||
|
|
return navigator.locks?.request?navigator.locks.request(worldLockName,{mode:'exclusive'},task):withStorageLease(worldLockName,task);
|
|||
|
|
}
|
|||
|
|
function enqueueWorldMutation(task){
|
|||
|
|
const run=()=>withWorldMutationLock(task);
|
|||
|
|
const result=worldMutationQueue.then(run,run);worldMutationQueue=result.catch(()=>{});return result;
|
|||
|
|
}
|
|||
|
|
function occupiedNeighborCount(unitX,unitY){
|
|||
|
|
let count=0;
|
|||
|
|
for(const[dx,dy]of[[0,-1],[0,1],[-1,0],[1,0]])if(occupancy.has(key2(unitX+dx,unitY+dy)))count++;
|
|||
|
|
return count;
|
|||
|
|
}
|
|||
|
|
function isClosedVoidUnit(unitX,unitY){return!occupancy.has(key2(unitX,unitY))&&occupiedNeighborCount(unitX,unitY)===4}
|
|||
|
|
function frontierCandidates(meta){
|
|||
|
|
if(!meta)return[];
|
|||
|
|
const own=new Set(meta.chunks.map(([dx,dy])=>key2(meta.x+dx,meta.y+dy))),map=new Map();
|
|||
|
|
for(const[dx,dy]of meta.chunks){
|
|||
|
|
const unitX=meta.x+dx,unitY=meta.y+dy;
|
|||
|
|
for(const side of['N','S','W','E']){
|
|||
|
|
const[dr,dc]=SIDE_D[side],targetX=unitX+dc,targetY=unitY+dr,key=key2(targetX,targetY);
|
|||
|
|
if(own.has(key)||occupancy.has(key)||isClosedVoidUnit(targetX,targetY))continue;
|
|||
|
|
const existing=map.get(key)||{unitX:targetX,unitY:targetY,side,targetSide:OPP[side],contacts:[]};
|
|||
|
|
existing.contacts.push({unitX,unitY,side});map.set(key,existing);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return[...map.values()].sort((a,b)=>{
|
|||
|
|
const ah=hash32(meta.seed^Math.imul(a.unitX+8191,73856093)^Math.imul(a.unitY-12289,19349663)),bh=hash32(meta.seed^Math.imul(b.unitX+8191,73856093)^Math.imul(b.unitY-12289,19349663));
|
|||
|
|
return(ah>>>0)-(bh>>>0)||a.unitY-b.unitY||a.unitX-b.unitX;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
function repairFacingGateConnections(meta){
|
|||
|
|
if(!meta?.puzzle)return 0;
|
|||
|
|
const p=puzzleOf(meta);let changed=0;
|
|||
|
|
for(let gateIndex=0;gateIndex<p.g.length;gateIndex++){
|
|||
|
|
if(!gateConnectionAllowed(meta,gateIndex))continue;
|
|||
|
|
const g=p.g[gateIndex],[gr,gc]=globalCell(meta,[g[0],g[1]]),[dr,dc]=SIDE_D[g[2]],nr=gr+dr,nc=gc+dc,
|
|||
|
|
neighbor=metaAtGlobalCell(nr,nc);
|
|||
|
|
if(!neighbor||neighbor.id===meta.id||!neighbor.puzzle)continue;
|
|||
|
|
const np=puzzleOf(neighbor),lr=nr-neighbor.y*5,lc=nc-neighbor.x*5,neighborGate=gateAtCell(np,[lr,lc],null,OPP[g[2]]);
|
|||
|
|
if(neighborGate==null||(neighbor.sealedSides||[]).includes(OPP[g[2]])===false)continue;
|
|||
|
|
neighbor.sealedSides=neighbor.sealedSides.filter(side=>side!==OPP[g[2]]);
|
|||
|
|
neighbor.rev=nextRevision();markMetaDirty(neighbor.id);changed++;
|
|||
|
|
}
|
|||
|
|
if(changed)adjacencyCache.clear();
|
|||
|
|
return changed;
|
|||
|
|
}
|
|||
|
|
function gateFrontierCandidates(meta){
|
|||
|
|
if(!meta?.puzzle)return[];
|
|||
|
|
const p=puzzleOf(meta),map=new Map();
|
|||
|
|
for(let gateIndex=0;gateIndex<p.g.length;gateIndex++){
|
|||
|
|
if(!gateConnectionAllowed(meta,gateIndex)||matchingNeighborGate(meta,gateIndex))continue;
|
|||
|
|
const g=p.g[gateIndex],[gr,gc]=globalCell(meta,[g[0],g[1]]),[dr,dc]=SIDE_D[g[2]],nr=gr+dr,nc=gc+dc,
|
|||
|
|
targetUnitX=Math.floor(nc/5),targetUnitY=Math.floor(nr/5),key=key2(targetUnitX,targetUnitY);
|
|||
|
|
if(occupancy.has(key))continue;
|
|||
|
|
const existing=map.get(key)||{unitX:targetUnitX,unitY:targetUnitY,side:g[2],targetSide:OPP[g[2]],contacts:[],gateDriven:true,terminalFill:isClosedVoidUnit(targetUnitX,targetUnitY)};
|
|||
|
|
existing.contacts.push({unitX:Math.floor(gc/5),unitY:Math.floor(gr/5),side:g[2],gateIndex,globalRow:gr,globalCol:gc});
|
|||
|
|
map.set(key,existing);
|
|||
|
|
}
|
|||
|
|
return[...map.values()].sort((a,b)=>{
|
|||
|
|
const ai=Math.min(...a.contacts.map(contact=>contact.gateIndex)),bi=Math.min(...b.contacts.map(contact=>contact.gateIndex));
|
|||
|
|
return ai-bi||a.unitY-b.unitY||a.unitX-b.unitX;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
function unresolvedExpansionCandidates(meta){return gateFrontierCandidates(meta)}
|
|||
|
|
function closedVoidRepairCandidates(){
|
|||
|
|
const candidates=[];
|
|||
|
|
for(const key of closedVoidKeys){
|
|||
|
|
const[unitX,unitY]=key.split(',').map(Number);if(!isClosedVoidUnit(unitX,unitY)){closedVoidKeys.delete(key);continue}
|
|||
|
|
let candidate=null;
|
|||
|
|
for(const side of['N','S','W','E']){
|
|||
|
|
const[dr,dc]=SIDE_D[side],sourceId=occupancy.get(key2(unitX-dc,unitY-dr)),source=data.metas[sourceId];
|
|||
|
|
if(!source?.chunks?.length||!metaState(source.id).solved)continue;
|
|||
|
|
const gateFrontier=gateFrontierCandidates(source).find(frontier=>frontier.unitX===unitX&&frontier.unitY===unitY);
|
|||
|
|
candidate={source,frontier:gateFrontier||{unitX,unitY,side,targetSide:OPP[side],contacts:[],gateDriven:false,terminalFill:true,closedVoidRepair:true}};break;
|
|||
|
|
}
|
|||
|
|
if(candidate)candidates.push(candidate);
|
|||
|
|
}
|
|||
|
|
return candidates.sort((left,right)=>left.frontier.unitY-right.frontier.unitY||left.frontier.unitX-right.frontier.unitX||left.source.id.localeCompare(right.source.id,undefined,{numeric:true}));
|
|||
|
|
}
|
|||
|
|
function missingGateConnections(meta){
|
|||
|
|
if(!meta?.puzzle)return[];
|
|||
|
|
const p=puzzleOf(meta),missing=[];
|
|||
|
|
for(let gateIndex=0;gateIndex<p.g.length;gateIndex++){
|
|||
|
|
if(!gateConnectionAllowed(meta,gateIndex)||matchingNeighborGate(meta,gateIndex))continue;
|
|||
|
|
const g=p.g[gateIndex],[gr,gc]=globalCell(meta,[g[0],g[1]]),[dr,dc]=SIDE_D[g[2]],nr=gr+dr,nc=gc+dc,
|
|||
|
|
blocker=metaAtGlobalCell(nr,nc);
|
|||
|
|
missing.push({gateIndex,side:g[2],globalRow:gr,globalCol:gc,neighborRow:nr,neighborCol:nc,blockedBy:blocker?.id||null});
|
|||
|
|
}
|
|||
|
|
return missing;
|
|||
|
|
}
|
|||
|
|
function placementConnectionRequirements(x,y,chunks){
|
|||
|
|
const placed=placedShapeKeys(x,y,chunks),requirements=[],neighborIds=new Set();
|
|||
|
|
for(const key of placed){const[unitX,unitY]=key.split(',').map(Number);for(const[dr,dc]of Object.values(SIDE_D)){const id=occupancy.get(key2(unitX+dc,unitY+dr));if(id)neighborIds.add(id)}}
|
|||
|
|
for(const id of neighborIds){
|
|||
|
|
const meta=data.metas[id];
|
|||
|
|
if(!meta?.puzzle)continue;
|
|||
|
|
const p=puzzleOf(meta);
|
|||
|
|
for(let gateIndex=0;gateIndex<p.g.length;gateIndex++){
|
|||
|
|
if(!gateConnectionAllowed(meta,gateIndex)||matchingNeighborGate(meta,gateIndex))continue;
|
|||
|
|
const g=p.g[gateIndex],[gr,gc]=globalCell(meta,[g[0],g[1]]),[dr,dc]=SIDE_D[g[2]],nr=gr+dr,nc=gc+dc,
|
|||
|
|
targetUnitX=Math.floor(nc/5),targetUnitY=Math.floor(nr/5);
|
|||
|
|
if(!placed.has(key2(targetUnitX,targetUnitY)))continue;
|
|||
|
|
requirements.push({metaId:meta.id,gateIndex,sourceSide:g[2],targetSide:OPP[g[2]],neighborRow:nr,neighborCol:nc});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return requirements;
|
|||
|
|
}
|
|||
|
|
function puzzleSupportsConnectionRequirements(puzzle,x,y,requirements){
|
|||
|
|
if(!requirements.length)return true;
|
|||
|
|
const valid=cellSet(puzzle);
|
|||
|
|
for(const requirement of requirements){
|
|||
|
|
const localRow=requirement.neighborRow-y*5,localCol=requirement.neighborCol-x*5;
|
|||
|
|
if(!valid.has(ckey(localRow,localCol)))return false;
|
|||
|
|
if(gateAtCell(puzzle,[localRow,localCol],null,requirement.targetSide)==null)return false;
|
|||
|
|
}
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
function sameNumberList(a,b){return a.length===b.length&&a.every((value,index)=>value===b[index])}
|
|||
|
|
function fixedPortProfilesForRequirements(requirements){
|
|||
|
|
const groups=new Map();
|
|||
|
|
for(const requirement of requirements){
|
|||
|
|
const unitX=Math.floor(requirement.neighborCol/5),unitY=Math.floor(requirement.neighborRow/5),side=requirement.targetSide;
|
|||
|
|
let key,offset,profiles;
|
|||
|
|
if(side==='N'||side==='S'){
|
|||
|
|
key=horizontalBoundaryKey(unitX,side==='N'?unitY:unitY+1);offset=requirement.neighborCol-unitX*5;profiles=H_PORT_PROFILES;
|
|||
|
|
}else{
|
|||
|
|
key=verticalBoundaryKey(side==='W'?unitX:unitX+1,unitY);offset=requirement.neighborRow-unitY*5;profiles=V_PORT_PROFILES;
|
|||
|
|
}
|
|||
|
|
const group=groups.get(key)||{profiles,offsets:new Set()};group.offsets.add(offset);groups.set(key,group);
|
|||
|
|
}
|
|||
|
|
const fixed={};
|
|||
|
|
for(const[key,{profiles,offsets}]of groups){
|
|||
|
|
const values=[...offsets].sort((a,b)=>a-b),index=profiles.findIndex(profile=>sameNumberList(profile,values));
|
|||
|
|
if(index<0)return null;
|
|||
|
|
fixed[key]=index;
|
|||
|
|
}
|
|||
|
|
return fixed;
|
|||
|
|
}
|
|||
|
|
function generatedPuzzleIssue(puzzle){
|
|||
|
|
if(!puzzle||!Array.isArray(puzzle.valid)||!Array.isArray(puzzle.g)||!Array.isArray(puzzle.n)||!Array.isArray(puzzle.solution))return'\u76e4\u9762\u30c7\u30fc\u30bf\u4e0d\u8db3';
|
|||
|
|
if(!puzzle.valid.length||!puzzle.g.length||puzzle.g.length%2||puzzle.solution.length!==puzzle.n.length)return'\u7dda\u3068\u30b2\u30fc\u30c8\u306e\u6570\u304c\u4e0d\u6b63';
|
|||
|
|
const special=specialCellSet(puzzle),crossingCoverage=new Set(special.crossings.map(cell=>ckey(...cell))),valid=new Set(),coverage=new Map(),usedGates=new Set();
|
|||
|
|
for(const cell of puzzle.valid){const key=ckey(...cell);if(valid.has(key))return'\u6709\u52b9\u30bb\u30eb\u91cd\u8907';valid.add(key)}
|
|||
|
|
for(const path of puzzle.solution){
|
|||
|
|
if(!path||!Array.isArray(path.cells)||!path.cells.length||!Number.isInteger(path.startGate)||!Number.isInteger(path.endGate)||path.startGate===path.endGate)return'\u89e3\u7b54\u7dda\u4e0d\u6b63';
|
|||
|
|
if(path.startGate<0||path.endGate<0||path.startGate>=puzzle.g.length||path.endGate>=puzzle.g.length||usedGates.has(path.startGate)||usedGates.has(path.endGate))return'\u30b2\u30fc\u30c8\u91cd\u8907';
|
|||
|
|
usedGates.add(path.startGate);usedGates.add(path.endGate);
|
|||
|
|
if(!sameCell(path.cells[0],puzzle.g[path.startGate])||!sameCell(path.cells[path.cells.length-1],puzzle.g[path.endGate]))return'\u89e3\u7b54\u7dda\u3068\u30b2\u30fc\u30c8\u306e\u4e0d\u6574\u5408';
|
|||
|
|
const own=new Set();
|
|||
|
|
for(let index=0;index<path.cells.length;index++){
|
|||
|
|
const cell=path.cells[index],key=ckey(...cell);if(!valid.has(key)||own.has(key))return'\u89e3\u7b54\u7dda\u30bb\u30eb\u4e0d\u6b63';
|
|||
|
|
if(index&& !pathCellsAdjacent(puzzle,path.cells[index-1],cell))return'\u89e3\u7b54\u7dda\u304c\u5206\u65ad';
|
|||
|
|
own.add(key);coverage.set(key,(coverage.get(key)||0)+1);
|
|||
|
|
}
|
|||
|
|
if(!pathValid(path,puzzle))return'\u66f2\u304c\u308a\u6570\u307e\u305f\u306f\u7279\u6b8a\u30bb\u30eb\u304c\u4e0d\u6574\u5408';
|
|||
|
|
}
|
|||
|
|
if(usedGates.size!==puzzle.g.length)return'\u672a\u4f7f\u7528\u30b2\u30fc\u30c8';
|
|||
|
|
for(const key of valid)if((coverage.get(key)||0)!==(crossingCoverage.has(key)?2:1))return'\u672a\u88ab\u8986\u30bb\u30eb';
|
|||
|
|
for(const cell of special.crossings){
|
|||
|
|
if(!valid.has(ckey(...cell)))return'\u4ea4\u5dee\u30bb\u30eb\u304c\u76e4\u9762\u5916';
|
|||
|
|
for(const neighbor of[[cell[0]-1,cell[1]],[cell[0]+1,cell[1]],[cell[0],cell[1]-1],[cell[0],cell[1]+1]])if(!valid.has(ckey(...neighbor)))return'\u4ea4\u5dee\u30bb\u30eb\u304c\u5916\u5468';
|
|||
|
|
}
|
|||
|
|
for(const pair of special.warps)if(!valid.has(ckey(...pair.a))||!valid.has(ckey(...pair.b)))return'\u30ef\u30fc\u30d7\u30bb\u30eb\u304c\u76e4\u9762\u5916';
|
|||
|
|
for(const lock of special.locks)if(!valid.has(ckey(...lock.key))||!valid.has(ckey(...lock.door)))return'\u9375\u307e\u305f\u306f\u6249\u304c\u76e4\u9762\u5916';
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function obstacleDetours(puzzle){
|
|||
|
|
const candidates=[];
|
|||
|
|
for(let pathIndex=0;pathIndex<puzzle.solution.length;pathIndex++){
|
|||
|
|
const cells=puzzle.solution[pathIndex].cells;
|
|||
|
|
for(let start=1;start<cells.length-2;start++)for(let end=start;end<Math.min(cells.length-1,start+8);end++){
|
|||
|
|
if(manhattan(cells[start-1],cells[end+1])!==1)continue;
|
|||
|
|
const nextCells=[...cells.slice(0,start),...cells.slice(end+1)],test={...puzzle.solution[pathIndex],cells:nextCells};
|
|||
|
|
if(nextCells.length<2||turnAnalysis(test,puzzle).count<1)continue;
|
|||
|
|
candidates.push({pathIndex,start,end,cells:cells.slice(start,end+1)});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return candidates;
|
|||
|
|
}
|
|||
|
|
function obstacleCellLimit(puzzle){
|
|||
|
|
const total=(puzzle?.valid?.length||0)+(puzzle?.obstacles?.length||0);
|
|||
|
|
return Math.max(0,Math.floor(total*.2));
|
|||
|
|
}
|
|||
|
|
function addObstaclePattern(sourcePuzzle,seed){
|
|||
|
|
const puzzle=deepClone(sourcePuzzle),rng=rngFrom(hash32(seed^0x4f1bbcdc)),planned=new Set(),allCells=shuffle([...puzzle.valid],rng),
|
|||
|
|
totalCells=allCells.length,largePuzzleBoost=Math.min(.04,Math.max(0,totalCells-25)/225*.04),baseRate=.02+largePuzzleBoost,maxObstacles=obstacleCellLimit(puzzle);
|
|||
|
|
for(const[r,c]of allCells){
|
|||
|
|
if(planned.size>=maxObstacles)break;
|
|||
|
|
const adjacent=[[1,0],[-1,0],[0,1],[0,-1]].some(([dr,dc])=>planned.has(ckey(r+dr,c+dc))),rate=Math.min(.18,baseRate+(adjacent?.09:0));
|
|||
|
|
if(rng()<rate)planned.add(ckey(r,c));
|
|||
|
|
}
|
|||
|
|
const targetCount=Math.min(planned.size,maxObstacles);if(!targetCount){puzzle.obstacles=[];return puzzle}
|
|||
|
|
const obstacles=new Map();
|
|||
|
|
for(let round=0;round<targetCount*3&&obstacles.size<targetCount;round++){
|
|||
|
|
const remaining=targetCount-obstacles.size,allCandidates=obstacleDetours(puzzle);let candidates=allCandidates.filter(candidate=>candidate.cells.length<=remaining);
|
|||
|
|
if(!candidates.length)break;
|
|||
|
|
candidates.sort((a,b)=>{
|
|||
|
|
const aPlanned=a.cells.reduce((sum,cell)=>sum+(planned.has(ckey(...cell))?1:0),0),bPlanned=b.cells.reduce((sum,cell)=>sum+(planned.has(ckey(...cell))?1:0),0),
|
|||
|
|
aAdjacent=a.cells.some(([r,c])=>[[1,0],[-1,0],[0,1],[0,-1]].some(([dr,dc])=>obstacles.has(ckey(r+dr,c+dc))))?1:0,
|
|||
|
|
bAdjacent=b.cells.some(([r,c])=>[[1,0],[-1,0],[0,1],[0,-1]].some(([dr,dc])=>obstacles.has(ckey(r+dr,c+dc))))?1:0;
|
|||
|
|
return(bPlanned*100+bAdjacent*12-b.cells.length)-(aPlanned*100+aAdjacent*12-a.cells.length)||a.cells.length-b.cells.length||rng()-.5;
|
|||
|
|
});
|
|||
|
|
const chosen=candidates[0],path=puzzle.solution[chosen.pathIndex];path.cells=[...path.cells.slice(0,chosen.start),...path.cells.slice(chosen.end+1)];for(const cell of chosen.cells)obstacles.set(ckey(...cell),cell);
|
|||
|
|
}
|
|||
|
|
if(!obstacles.size){puzzle.obstacles=[];return puzzle}
|
|||
|
|
puzzle.obstacles=[...obstacles.values()];puzzle.valid=puzzle.valid.filter(cell=>!obstacles.has(ckey(...cell)));puzzle.n=[];let maxTurns=0,totalTurns=0;
|
|||
|
|
const clueRng=rngFrom(hash32(seed^0x2c1b3c6d));
|
|||
|
|
for(const path of puzzle.solution){const analysis=turnAnalysis(path,puzzle);if(!analysis.count||!analysis.cells.length)return sourcePuzzle;const clue=analysis.cells[Math.floor(clueRng()*analysis.cells.length)];puzzle.n.push([clue[0],clue[1],analysis.count]);maxTurns=Math.max(maxTurns,analysis.count);totalTurns+=analysis.count}
|
|||
|
|
puzzle.maxTurns=maxTurns;puzzle.totalTurns=totalTurns;puzzle.style=`${puzzle.style||'procedural'}-obstacles`;
|
|||
|
|
puzzle.difficulty=sourcePuzzle.difficulty;puzzle.level=sourcePuzzle.level??sourcePuzzle.difficulty
|
|||
|
|
return puzzle;
|
|||
|
|
}
|
|||
|
|
function specialCellSet(p){
|
|||
|
|
if(!p.specialCells)p.specialCells={crossings:[],warps:[],locks:[]};
|
|||
|
|
if(!Array.isArray(p.specialCells.crossings))p.specialCells.crossings=[];
|
|||
|
|
if(!Array.isArray(p.specialCells.warps))p.specialCells.warps=[];
|
|||
|
|
if(!Array.isArray(p.specialCells.locks))p.specialCells.locks=[];
|
|||
|
|
return p.specialCells;
|
|||
|
|
}
|
|||
|
|
function invalidateSpecialCellCaches(p){if(p){delete p._warpMap}}
|
|||
|
|
function reservedSpecialKeys(p){
|
|||
|
|
const keys=new Set(),special=specialCellSet(p);
|
|||
|
|
for(const cell of special.crossings)keys.add(ckey(...cell));
|
|||
|
|
for(const pair of special.warps){keys.add(ckey(...pair.a));keys.add(ckey(...pair.b))}
|
|||
|
|
for(const lock of special.locks){keys.add(ckey(...lock.key));keys.add(ckey(...lock.door))}
|
|||
|
|
return keys;
|
|||
|
|
}
|
|||
|
|
function rebuildSolutionClues(p,seed){
|
|||
|
|
const reserved=reservedSpecialKeys(p),rng=rngFrom(hash32(seed^0x3c6ef372));p.n=[];let maxTurns=0,totalTurns=0;
|
|||
|
|
for(const path of p.solution){
|
|||
|
|
const analysis=turnAnalysis(path,p);if(!analysis.count||!analysis.cells.length)return false;
|
|||
|
|
const pool=analysis.cells.filter(cell=>!reserved.has(ckey(...cell))),candidates=pool.length?pool:analysis.cells,
|
|||
|
|
clue=candidates[Math.floor(rng()*candidates.length)];
|
|||
|
|
p.n.push([clue[0],clue[1],analysis.count]);maxTurns=Math.max(maxTurns,analysis.count);totalTurns+=analysis.count;
|
|||
|
|
}
|
|||
|
|
p.maxTurns=maxTurns;p.totalTurns=totalTurns;return true;
|
|||
|
|
}
|
|||
|
|
function addWarpSpecial(p,rng,reserved){
|
|||
|
|
const candidates=[];
|
|||
|
|
p.solution.forEach((path,pathIndex)=>{
|
|||
|
|
const cells=path.cells;
|
|||
|
|
for(let i=1;i<cells.length-5;i++)for(let j=i+3;j<cells.length-2;j++){
|
|||
|
|
const a=cells[i],b=cells[j],afterA=cells[i+1],afterB=cells[j+1];
|
|||
|
|
if(reserved.has(ckey(...a))||reserved.has(ckey(...b))||manhattan(a,b)<=2||manhattan(afterA,afterB)!==1)continue;
|
|||
|
|
candidates.push({pathIndex,i,j,a:[...a],b:[...b],distance:manhattan(a,b)});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
if(!candidates.length)return false;
|
|||
|
|
candidates.sort((a,b)=>b.distance-a.distance||rng()-.5);const chosen=candidates[Math.floor(rng()*Math.min(8,candidates.length))],path=p.solution[chosen.pathIndex],cells=path.cells;
|
|||
|
|
path.cells=[...cells.slice(0,chosen.i+1),...cells.slice(chosen.i+1,chosen.j+1).reverse(),...cells.slice(chosen.j+1)];
|
|||
|
|
specialCellSet(p).warps.push({a:chosen.a,b:chosen.b});invalidateSpecialCellCaches(p);reserved.add(ckey(...chosen.a));reserved.add(ckey(...chosen.b));return true;
|
|||
|
|
}
|
|||
|
|
function addLockSpecial(p,rng,reserved){
|
|||
|
|
const candidates=[];
|
|||
|
|
p.solution.forEach((path,pathIndex)=>{
|
|||
|
|
const cells=path.cells;
|
|||
|
|
for(let keyIndex=1;keyIndex<cells.length-5;keyIndex++)for(let doorIndex=keyIndex+3;doorIndex<cells.length-1;doorIndex++){
|
|||
|
|
const keyCell=cells[keyIndex],door=cells[doorIndex];
|
|||
|
|
if(reserved.has(ckey(...keyCell))||reserved.has(ckey(...door)))continue;
|
|||
|
|
candidates.push({pathIndex,keyIndex,doorIndex,key:[...keyCell],door:[...door],span:doorIndex-keyIndex});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
if(!candidates.length)return false;
|
|||
|
|
candidates.sort((a,b)=>b.span-a.span||rng()-.5);const chosen=candidates[Math.floor(rng()*Math.min(10,candidates.length))];
|
|||
|
|
specialCellSet(p).locks.push({key:chosen.key,door:chosen.door});reserved.add(ckey(...chosen.key));reserved.add(ckey(...chosen.door));return true;
|
|||
|
|
}
|
|||
|
|
function buildCrossingTemplate(p,reserved){
|
|||
|
|
if(reserved.size)return false;
|
|||
|
|
const allCells=[...(p.valid||[]),...(p.obstacles||[])],chunkSet=new Set(allCells.map(([r,c])=>key2(Math.floor(c/5),Math.floor(r/5)))),chunks=[...chunkSet].map(key=>key.split(',').map(Number));
|
|||
|
|
const directions=[['N',0,-1],['E',1,0],['S',0,1],['W',-1,0]],rotateSide=(side,turns)=>directions[(directions.findIndex(item=>item[0]===side)+turns)%4][0],rotateCell=([r,c],turns)=>{let row=r,col=c;for(let i=0;i<turns;i++)[row,col]=[col,4-row];return[row,col]};
|
|||
|
|
let chosen=null;
|
|||
|
|
for(const[chunkX,chunkY]of chunks){
|
|||
|
|
const external=new Set(directions.filter(([,dx,dy])=>!chunkSet.has(key2(chunkX+dx,chunkY+dy))).map(item=>item[0]));
|
|||
|
|
for(let turns=0;turns<4;turns++)if(external.has(rotateSide('N',turns))&&external.has(rotateSide('W',turns))){chosen={chunkX,chunkY,turns};break}
|
|||
|
|
if(chosen)break;
|
|||
|
|
}
|
|||
|
|
if(!chosen)return false;
|
|||
|
|
const basePaths=[
|
|||
|
|
{startGate:0,endGate:1,openGate:null,cells:[[2,0],[1,0],[1,1],[2,1],[2,2],[2,3],[3,3],[4,3],[4,4],[3,4],[2,4],[1,4],[1,3],[0,3]]},
|
|||
|
|
{startGate:2,endGate:3,openGate:null,cells:[[0,1],[0,2],[1,2],[2,2],[3,2],[4,2],[4,1],[4,0]]}
|
|||
|
|
],baseGates=[[2,0,'W'],[0,3,'N'],[0,1,'N'],[4,0,'W']],offsetCell=cell=>{const[row,col]=rotateCell(cell,chosen.turns);return[chosen.chunkY*5+row,chosen.chunkX*5+col]};
|
|||
|
|
p.solution=basePaths.map(path=>({...path,cells:path.cells.map(offsetCell)}));
|
|||
|
|
p.g=baseGates.map(([r,c,side])=>{const[row,col]=offsetCell([r,c]);return[row,col,rotateSide(side,chosen.turns)]});
|
|||
|
|
const crossing=offsetCell([2,2]),validKeys=new Set();for(const path of p.solution)for(const cell of path.cells)validKeys.add(ckey(...cell));
|
|||
|
|
const nextValid=allCells.filter(cell=>validKeys.has(ckey(...cell))),nextObstacles=allCells.filter(cell=>!validKeys.has(ckey(...cell)));
|
|||
|
|
if(nextObstacles.length>obstacleCellLimit(p))return false;
|
|||
|
|
p.valid=nextValid;p.obstacles=nextObstacles;p.n=[];p.specialCells={crossings:[crossing],warps:[],locks:[]};invalidateSpecialCellCaches(p);reserved.add(ckey(...crossing));return true;
|
|||
|
|
}
|
|||
|
|
function addCrossingSpecial(p,rng,reserved){
|
|||
|
|
const valid=cellSet(p),gateCells=new Set(p.g.map(g=>ckey(g[0],g[1]))),candidates=[];
|
|||
|
|
p.solution.forEach((basePath,basePathIndex)=>{
|
|||
|
|
for(let baseIndex=1;baseIndex<basePath.cells.length-1;baseIndex++){
|
|||
|
|
const cell=basePath.cells[baseIndex],previous=basePath.cells[baseIndex-1],next=basePath.cells[baseIndex+1],key=ckey(...cell);
|
|||
|
|
if(reserved.has(key)||gateCells.has(key))continue;
|
|||
|
|
const north=[cell[0]-1,cell[1]],south=[cell[0]+1,cell[1]],west=[cell[0],cell[1]-1],east=[cell[0],cell[1]+1],neighbors=[north,south,west,east];
|
|||
|
|
if(neighbors.some(candidate=>!valid.has(ckey(...candidate))))continue;
|
|||
|
|
const baseAxis=previous[0]===cell[0]&&next[0]===cell[0]?'H':previous[1]===cell[1]&&next[1]===cell[1]?'V':null;
|
|||
|
|
if(!baseAxis)continue;
|
|||
|
|
const [entry,exit]=baseAxis==='H'?[north,south]:[west,east];
|
|||
|
|
p.solution.forEach((crossPath,crossPathIndex)=>{
|
|||
|
|
if(crossPathIndex===basePathIndex||crossPath.cells.some(candidate=>sameCell(candidate,cell)))return;
|
|||
|
|
const entryIndex=crossPath.cells.findIndex(candidate=>sameCell(candidate,entry)),exitIndex=crossPath.cells.findIndex(candidate=>sameCell(candidate,exit));
|
|||
|
|
if(entryIndex<0||exitIndex<0||Math.abs(entryIndex-exitIndex)<2)return;
|
|||
|
|
const lo=Math.min(entryIndex,exitIndex),hi=Math.max(entryIndex,exitIndex),removed=crossPath.cells.slice(lo+1,hi);
|
|||
|
|
if(!removed.length||(p.obstacles?.length||0)+removed.length>obstacleCellLimit(p)||removed.some(candidate=>sameCell(candidate,cell)||reserved.has(ckey(...candidate))||gateCells.has(ckey(...candidate))))return;
|
|||
|
|
for(let index=lo+1;index<=hi;index++)if(isWarpTransition(p,crossPath.cells[index-1],crossPath.cells[index]))return;
|
|||
|
|
candidates.push({cell:[...cell],basePathIndex,crossPathIndex,lo,hi,removed:removed.map(candidate=>[...candidate]),score:removed.length+Math.min(baseIndex,basePath.cells.length-1-baseIndex)});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
if(!candidates.length)return buildCrossingTemplate(p,reserved);
|
|||
|
|
candidates.sort((a,b)=>a.removed.length-b.removed.length||b.score-a.score||rng()-.5);const chosen=candidates[Math.floor(rng()*Math.min(12,candidates.length))],crossPath=p.solution[chosen.crossPathIndex];
|
|||
|
|
crossPath.cells=[...crossPath.cells.slice(0,chosen.lo+1),[...chosen.cell],...crossPath.cells.slice(chosen.hi)];
|
|||
|
|
const removedKeys=new Set(chosen.removed.map(cell=>ckey(...cell)));
|
|||
|
|
p.valid=p.valid.filter(cell=>!removedKeys.has(ckey(...cell)));p.obstacles=[...(p.obstacles||[]),...chosen.removed];
|
|||
|
|
specialCellSet(p).crossings.push(chosen.cell);invalidateSpecialCellCaches(p);reserved.add(ckey(...chosen.cell));
|
|||
|
|
return Boolean(crossingStateAtCell({paths:p.solution},p,chosen.cell));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function recentSpecialMechanicTypes(limit=3){
|
|||
|
|
const result=[],metas=Object.values(data.metas||{}).sort((left,right)=>(Number(right.id?.slice(1))||0)-(Number(left.id?.slice(1))||0));
|
|||
|
|
for(const meta of metas)for(const type of mechanicTypesForPuzzle(meta.puzzle)){result.push(type);if(result.length>=limit)return result.reverse()}
|
|||
|
|
return result.reverse();
|
|||
|
|
}
|
|||
|
|
function addScheduledSpecial(puzzle,type,rng,reserved){
|
|||
|
|
if(type==='warp')return addWarpSpecial(puzzle,rng,reserved);
|
|||
|
|
if(type==='lock')return addLockSpecial(puzzle,rng,reserved);
|
|||
|
|
if(type==='crossing')return addCrossingSpecial(puzzle,rng,reserved);
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
function specialCellUsage(puzzle){
|
|||
|
|
const special=specialCellSet(puzzle);
|
|||
|
|
return(special.crossings?.length||0)+(special.warps?.length||0)*2+(special.locks?.length||0)*2;
|
|||
|
|
}
|
|||
|
|
function addSpecialCellPattern(sourcePuzzle,seed,level){
|
|||
|
|
const effectiveLevel=SPECIAL_CELL_DEBUG_ALL_LEVELS?Math.max(SPECIAL_CELL_MIN_LEVEL,level):level,
|
|||
|
|
schedule=AppLogic.specialSchedule(effectiveLevel,sourcePuzzle.valid?.length||0,seed,data.specialMechanicsSeen||[],recentSpecialMechanicTypes());
|
|||
|
|
if(!schedule.types.length)return sourcePuzzle;
|
|||
|
|
const puzzle=deepClone(sourcePuzzle),special=specialCellSet(puzzle),reserved=reservedSpecialKeys(puzzle);
|
|||
|
|
special.crossings=[];special.warps=[];special.locks=[];invalidateSpecialCellCaches(puzzle);reserved.clear();
|
|||
|
|
const plan=[...schedule.types];for(let index=plan.length;index<schedule.setCount;index++)plan.push(schedule.types[index%schedule.types.length]);
|
|||
|
|
let added=0;
|
|||
|
|
for(let index=0;index<plan.length;index++){
|
|||
|
|
const type=plan[index],cellCost=type==='crossing'?1:2;
|
|||
|
|
if(specialCellUsage(puzzle)+cellCost>schedule.maxCells){if(index<schedule.types.length)return null;continue}
|
|||
|
|
const rng=rngFrom(hash32(seed^AppLogic.stableHash(['scheduled-special',type,index])));
|
|||
|
|
if(addScheduledSpecial(puzzle,type,rng,reserved))added++;
|
|||
|
|
else if(index<schedule.types.length)return null;
|
|||
|
|
}
|
|||
|
|
if(added<schedule.types.length)return null;
|
|||
|
|
if(!rebuildSolutionClues(puzzle,seed))return null;
|
|||
|
|
if(special.crossings.length&&!isSolved({paths:deepClone(puzzle.solution),specialProgress:{crossings:[]}},puzzle))return null;
|
|||
|
|
puzzle.specialSchedule={version:1,types:[...schedule.types],setCount:added,introduction:schedule.introduction===true};
|
|||
|
|
puzzle.style=`${puzzle.style||'procedural'}-special`;return puzzle;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function installPreparedChild(prepared){
|
|||
|
|
if(!prepared||prepared.futureBoardNumber!==data.nextId)return null;
|
|||
|
|
const source=data.metas[prepared.sourceId],frontier=prepared.frontier,puzzle=prepared.puzzle,shape=prepared.chunks,x=prepared.x,y=prepared.y;
|
|||
|
|
const terminalFill=frontier?.terminalFill===true&&isClosedVoidUnit(frontier.unitX,frontier.unitY);
|
|||
|
|
if(!source||!frontier||!puzzle||occupancy.has(key2(frontier.unitX,frontier.unitY))||isClosedVoidUnit(frontier.unitX,frontier.unitY)&&!terminalFill)return null;
|
|||
|
|
const targetRange=sectionCountRange(prepared.targetLevel);
|
|||
|
|
if(!fitsMeta(x,y,shape)||!placementPreservesUnsolvedFrontiers(x,y,shape,source.id,{allowTerminal:terminalFill})||targetLevelForNewBoard(x,y,shape,data.nextId)!==prepared.targetLevel||
|
|||
|
|
!terminalFill&&(shape.length<targetRange.min||shape.length>targetRange.max))return null;
|
|||
|
|
const connectionRequirements=placementConnectionRequirements(x,y,shape),required=new Set((frontier.contacts||[]).map(contact=>contact.gateIndex).filter(Number.isInteger));
|
|||
|
|
if(frontier.gateDriven&&[...required].some(gateIndex=>!connectionRequirements.some(requirement=>requirement.metaId===source.id&&requirement.gateIndex===gateIndex)))return null;
|
|||
|
|
if(!puzzleSupportsConnectionRequirements(puzzle,x,y,connectionRequirements))return null;
|
|||
|
|
const id='B'+data.nextId++,meta={id,x,y,chunks:shape,level:prepared.level,targetLevel:prepared.targetLevel,seed:prepared.generationSeed,axis:puzzle.axis,entrySide:prepared.targetSide,sealedSides:[...prepared.sealedSides],puzzle,justRevealed:true,generatorVersion:GENERATOR_VERSION,rev:nextRevision()};
|
|||
|
|
data.metas[id]=meta;
|
|||
|
|
const introduced=mechanicTypesForPuzzle(puzzle),seen=new Set(data.specialMechanicsSeen||[]);let encounterChanged=false;
|
|||
|
|
for(const type of introduced)if(!seen.has(type)){seen.add(type);encounterChanged=true}
|
|||
|
|
if(encounterChanged)data.specialMechanicsSeen=[...seen].sort();
|
|||
|
|
markMetaDirty(id);metaState(id);markStateDirty(id);addMetaToOccupancy(meta);return meta;
|
|||
|
|
}
|
|||
|
|
function nearbyShapeFamilyCounts(unitX,unitY,radius=7){
|
|||
|
|
const ids=new Set(),counts=new Map();
|
|||
|
|
for(let y=unitY-radius;y<=unitY+radius;y++)for(let x=unitX-radius;x<=unitX+radius;x++){const id=occupancy.get(key2(x,y));if(id)ids.add(id)}
|
|||
|
|
for(const id of ids){const chunks=data.metas[id]?.chunks;if(!chunks?.length)continue;const family=generatedShapeFamilyKey(chunks);counts.set(family,(counts.get(family)||0)+1)}
|
|||
|
|
return counts;
|
|||
|
|
}
|
|||
|
|
function shapeCandidatesForArea(candidates,seed,unitX,unitY){
|
|||
|
|
const balanced=balancedShapeCandidates(candidates,seed,{hash32,rngFrom,shuffle}),nearby=nearbyShapeFamilyCounts(unitX,unitY);
|
|||
|
|
return balanced.map((shape,index)=>({shape,index,count:nearby.get(generatedShapeFamilyKey(shape))||0})).sort((left,right)=>left.count-right.count||left.index-right.index).map(entry=>entry.shape);
|
|||
|
|
}
|
|||
|
|
async function placeChildAtFrontierAttempt(source,frontier,attemptBase=0,{prepareOnly=false,futureBoardNumber=data.nextId}={}){
|
|||
|
|
const targetUnitX=frontier.unitX,targetUnitY=frontier.unitY;
|
|||
|
|
const terminalFill=frontier.terminalFill===true&&isClosedVoidUnit(targetUnitX,targetUnitY);
|
|||
|
|
if(occupancy.has(key2(targetUnitX,targetUnitY))||isClosedVoidUnit(targetUnitX,targetUnitY)&&!terminalFill)return null;
|
|||
|
|
const targetSide=frontier.targetSide,preliminaryLevel=macroDifficulty(targetUnitX,targetUnitY),
|
|||
|
|
candidateSeed=hash32(source.seed^Math.imul(targetUnitX+101,49999)^Math.imul(targetUnitY-211,31337)^attemptBase);
|
|||
|
|
const tryShape=async(shape,seed,rng,{allowRegionalFallback=false}={})=>{
|
|||
|
|
const alignments=shuffle([...shape],rng);
|
|||
|
|
for(const[dx,dy]of alignments){
|
|||
|
|
const x=targetUnitX-dx,y=targetUnitY-dy,placementIsTerminal=terminalFill&&shape.length===1&&x===targetUnitX&&y===targetUnitY;
|
|||
|
|
if(!fitsMeta(x,y,shape)||!placementPreservesUnsolvedFrontiers(x,y,shape,source.id,{allowTerminal:placementIsTerminal}))continue;
|
|||
|
|
const targetLevel=targetLevelForNewBoard(x,y,shape,futureBoardNumber),actualRange=sectionCountRange(targetLevel);
|
|||
|
|
if(!placementIsTerminal&&(shape.length<actualRange.min||shape.length>actualRange.max))continue;
|
|||
|
|
const connectionRequirements=placementConnectionRequirements(x,y,shape),requiredFrontierGates=new Set((frontier.contacts||[]).map(contact=>contact.gateIndex).filter(Number.isInteger));
|
|||
|
|
if(frontier.gateDriven&&[...requiredFrontierGates].some(gateIndex=>!connectionRequirements.some(requirement=>requirement.metaId===source.id&&requirement.gateIndex===gateIndex)))continue;
|
|||
|
|
const fixedPortProfiles=fixedPortProfilesForRequirements(connectionRequirements);if(fixedPortProfiles==null)continue;
|
|||
|
|
let accepted=null,lastGenerationIssue=null;
|
|||
|
|
const generationAttempts=shape.length===1?5:4;
|
|||
|
|
for(let variant=0;variant<generationAttempts;variant++){
|
|||
|
|
const generationSeed=variant===0?seed:hash32(seed^Math.imul(variant+1,0x7f4a7c15)),
|
|||
|
|
portSeed=variant===0?null:hash32(generationSeed^0x243f6a88),obstacleSeed=hash32(generationSeed^0xb7e15162),specialSeed=hash32(generationSeed^0x9e3779b9),
|
|||
|
|
generationOptions=portSeed==null?{fixedPortProfiles}:{portSeed,fixedPortProfiles};
|
|||
|
|
let generated;
|
|||
|
|
try{generated=await generatePuzzleAsync(shape,generationSeed,targetLevel,x,y,shape.length>=7?9000:4000,generationOptions)}
|
|||
|
|
catch(error){lastGenerationIssue=error?.message||String(error);continue}
|
|||
|
|
let puzzle=addObstaclePattern(generated,obstacleSeed);puzzle=addSpecialCellPattern(puzzle,specialSeed,targetLevel);
|
|||
|
|
if(!puzzle){lastGenerationIssue='\u7279\u6b8a\u30bb\u30eb\u5272\u308a\u5f53\u3066\u4e0d\u6210\u7acb';continue}
|
|||
|
|
let issue=generatedPuzzleIssue(puzzle);
|
|||
|
|
if(!issue&&!puzzleSupportsConnectionRequirements(puzzle,x,y,connectionRequirements))issue='\u65e2\u5b58\u30b2\u30fc\u30c8\u3068\u4e0d\u4e00\u81f4';
|
|||
|
|
if(!issue&&!placementIsTerminal&&(shape.length<actualRange.min||shape.length>actualRange.max))issue='\u30ec\u30d9\u30eb\u5225\u306e\u533a\u753b\u6570\u7bc4\u56f2\u5916';
|
|||
|
|
const intrinsicLevel=solverDifficulty(puzzle,targetLevel),intrinsicInBand=difficultyFitsRegion(intrinsicLevel,targetLevel);
|
|||
|
|
const level=intrinsicLevel,inBand=intrinsicInBand;
|
|||
|
|
puzzle.difficulty=level;puzzle.level=level;puzzle.regionalTarget=targetLevel;
|
|||
|
|
if(!issue&&!inBand&&!allowRegionalFallback)issue='\u5730\u57df\u96e3\u6613\u5ea6\u7bc4\u56f2\u5916';
|
|||
|
|
const burden=AppLogic.interactionBurden(puzzle);puzzle.interactionBurden=burden;
|
|||
|
|
if(!issue&&burden.score>=39)issue='\u64cd\u4f5c\u8ca0\u62c5\u304c\u4e0a\u9650\u3092\u8d85\u904e';
|
|||
|
|
if(!issue&&level>=UNIQUE_SOLUTION_MIN_LEVEL){
|
|||
|
|
let verification;try{verification=await verifyPuzzleUniquenessAsync(puzzle)}catch(error){lastGenerationIssue=error?.message||String(error);continue}
|
|||
|
|
if(verification.status!=='unique')issue=`\u4e00\u610f\u89e3\u691c\u8a3c\uff1a${verification.status}`;
|
|||
|
|
else{
|
|||
|
|
const boundedBurdenPenalty=Math.min(14,burden.score*.35),qualityScore=Math.max(0,(verification.quality?.score||0)-boundedBurdenPenalty);
|
|||
|
|
puzzle.uniqueness={status:'unique',signature:verification.signature,ruleVersion:verification.ruleVersion,nodes:verification.nodes};
|
|||
|
|
puzzle.solutionQuality={...verification.quality,interactionBurdenPenalty:boundedBurdenPenalty,score:qualityScore,accepted:verification.quality?.accepted===true&&qualityScore>=10};
|
|||
|
|
if(!puzzle.solutionQuality.accepted)issue='\u89e3\u6cd5\u54c1\u8cea\u30b9\u30b3\u30a2\u4e0d\u8db3';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if(issue){lastGenerationIssue=issue;continue}
|
|||
|
|
accepted={puzzle,generationSeed,level,inBand,portSeed,obstacleSeed,specialSeed};break;
|
|||
|
|
}
|
|||
|
|
if(!accepted){if(globalThis.BEND_DEBUG_GENERATION&&lastGenerationIssue)console.debug('BEND FIELD: regenerated rejected board candidate',lastGenerationIssue);continue}
|
|||
|
|
const{puzzle,generationSeed,level,inBand}=accepted;
|
|||
|
|
if(!fitsMeta(x,y,shape)||!placementPreservesUnsolvedFrontiers(x,y,shape,source.id,{allowTerminal:placementIsTerminal})||occupancy.has(key2(targetUnitX,targetUnitY))||isClosedVoidUnit(targetUnitX,targetUnitY)&&!placementIsTerminal)continue;
|
|||
|
|
if(!inBand)puzzle.regionalFallback=true;
|
|||
|
|
const requiredOpenSides=new Set([...(frontier.gateDriven||!placementIsTerminal?[targetSide]:[]),...connectionRequirements.map(requirement=>requirement.targetSide)]);
|
|||
|
|
const sealedSides=(placementIsTerminal?['N','S','W','E']:[]).filter(side=>!requiredOpenSides.has(side)),
|
|||
|
|
prepared={sourceId:source.id,frontier:deepClone(frontier),futureBoardNumber,x,y,chunks:shape.map(cell=>[...cell]),level,targetLevel,generationSeed,targetSide,sealedSides,puzzle};
|
|||
|
|
return prepareOnly?prepared:installPreparedChild(prepared);
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
};
|
|||
|
|
const candidateLevels=new Set([preliminaryLevel,macroDifficulty(targetUnitX,targetUnitY)]);
|
|||
|
|
const candidates=[],candidateKeys=new Set();
|
|||
|
|
for(const levelCandidate of candidateLevels)for(const shape of shapeCandidatesForLevel(hash32(candidateSeed^Math.imul(levelCandidate,0x45d9f3b)),levelCandidate,12)){const key=generatedShapeKey(shape);if(!candidateKeys.has(key)){candidateKeys.add(key);candidates.push(shape)}}
|
|||
|
|
for(const shape of shuffle([...SHAPES],rngFrom(hash32(candidateSeed^0x3c6ef372)))){const key=generatedShapeKey(shape);if(!candidateKeys.has(key)){candidateKeys.add(key);candidates.push(shape)}}
|
|||
|
|
const orderedCandidates=shapeCandidatesForArea(candidates,hash32(candidateSeed^0xbb67ae85),targetUnitX,targetUnitY);
|
|||
|
|
for(let pass=0;pass<2;pass++)for(let index=0;index<orderedCandidates.length;index++){
|
|||
|
|
const shape=orderedCandidates[index],seed=hash32(candidateSeed^Math.imul(index+1,0x9e3779b9)^Math.imul(pass+1,0x45d9f3b)),made=await tryShape(shape,seed,rngFrom(seed),{allowRegionalFallback:pass===1});
|
|||
|
|
if(made)return made;
|
|||
|
|
}
|
|||
|
|
// Safe final fallback: a single 5x5 section at the exact gate frontier.
|
|||
|
|
// It is accepted only when every existing facing gate, occupancy rule, and
|
|||
|
|
// future-frontier constraint is still satisfied; no board is forced into place.
|
|||
|
|
const fallbackShape=[[0,0]];
|
|||
|
|
for(let attempt=0;attempt<4;attempt++){
|
|||
|
|
const seed=hash32(candidateSeed^0x6a09e667^Math.imul(attempt+1,0x85ebca6b)),made=await tryShape(fallbackShape,seed,rngFrom(seed),{allowRegionalFallback:true});
|
|||
|
|
if(made)return made;
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function frontierGeometryStillViable(source,frontier){
|
|||
|
|
if(!source||data.metas[source.id]!==source)return false;
|
|||
|
|
const targetKey=key2(frontier.unitX,frontier.unitY),terminalFill=frontier.terminalFill===true&&isClosedVoidUnit(frontier.unitX,frontier.unitY);
|
|||
|
|
if(occupancy.has(targetKey)||isClosedVoidUnit(frontier.unitX,frontier.unitY)&&!terminalFill)return false;
|
|||
|
|
const shapes=[...SHAPES,[[0,0]]],seen=new Set();
|
|||
|
|
for(const shape of shapes){
|
|||
|
|
const shapeKey=generatedShapeKey(shape);if(seen.has(shapeKey))continue;seen.add(shapeKey);
|
|||
|
|
for(const[dx,dy]of shape){
|
|||
|
|
const x=frontier.unitX-dx,y=frontier.unitY-dy;
|
|||
|
|
const placementIsTerminal=terminalFill&&shape.length===1&&x===frontier.unitX&&y===frontier.unitY;
|
|||
|
|
if(!fitsMeta(x,y,shape)||!placementPreservesUnsolvedFrontiers(x,y,shape,source.id,{allowTerminal:placementIsTerminal}))continue;
|
|||
|
|
const range=sectionCountRange(targetLevelForNewBoard(x,y,shape,data.nextId));if(!placementIsTerminal&&(shape.length<range.min||shape.length>range.max))continue;
|
|||
|
|
const requirements=placementConnectionRequirements(x,y,shape),required=new Set((frontier.contacts||[]).map(contact=>contact.gateIndex).filter(Number.isInteger));
|
|||
|
|
if(frontier.gateDriven&&[...required].some(gateIndex=>!requirements.some(requirement=>requirement.metaId===source.id&&requirement.gateIndex===gateIndex)))continue;
|
|||
|
|
if(fixedPortProfilesForRequirements(requirements)!=null)return true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
async function placeChildAtFrontier(source,frontier,attemptBase=0,options=null){
|
|||
|
|
let cycle=0;
|
|||
|
|
while(cycle<MAX_FRONTIER_GENERATION_CYCLES&&frontierGeometryStillViable(source,frontier)){
|
|||
|
|
const cycleBase=attemptBase+cycle*2000003,made=await placeChildAtFrontierAttempt(source,frontier,cycleBase,options||undefined);
|
|||
|
|
if(made)return made;
|
|||
|
|
cycle++;
|
|||
|
|
if(cycle%2===0){if(globalThis.BEND_DEBUG_GENERATION)console.debug('BEND FIELD: retrying full board generation',source.id,frontier.unitX,frontier.unitY,cycle);await sleep(0)}
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
function unsolvedBoardCount(){let count=0;for(const id of Object.keys(data.metas))if(!metaState(id).solved)count++;return count}
|
|||
|
|
async function ensureUnsolvedFrontier(preferredMeta=null){
|
|||
|
|
if(unsolvedBoardCount()>0)return 0;
|
|||
|
|
rebuildOccupancy();
|
|||
|
|
const metas=[];if(preferredMeta)metas.push(preferredMeta);
|
|||
|
|
for(const meta of Object.values(data.metas))if(meta!==preferredMeta&&metaState(meta.id).solved)metas.push(meta);
|
|||
|
|
metas.sort((a,b)=>(a===preferredMeta?-1:b===preferredMeta?1:0)||(b.rev||0)-(a.rev||0));
|
|||
|
|
for(const meta of metas){
|
|||
|
|
if(!meta.puzzle){try{await hydrateMeta(meta)}catch(_){continue}}
|
|||
|
|
await hydrateAdjacentMetas(meta);
|
|||
|
|
const gateCandidates=gateFrontierCandidates(meta),candidates=gateCandidates.length?gateCandidates:frontierCandidates(meta);
|
|||
|
|
for(let offset=0;offset<candidates.length;offset++)if(await placeChildAtFrontier(meta,candidates[offset],250000+offset*1000))return 1;
|
|||
|
|
}
|
|||
|
|
return 0;
|
|||
|
|
}
|
|||
|
|
async function prepareExpansionCandidate(meta){
|
|||
|
|
if(!meta||data.metas[meta.id]!==meta||!metaState(meta.id).solved)return null;
|
|||
|
|
const boardNumber=data.nextId,frontiers=unresolvedExpansionCandidates(meta);
|
|||
|
|
for(let index=0;index<frontiers.length;index++){
|
|||
|
|
const prepared=await placeChildAtFrontierAttempt(meta,frontiers[index],index*1000,{prepareOnly:true,futureBoardNumber:boardNumber});
|
|||
|
|
if(prepared)return prepared;
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
async function expandMetaNow(meta,prepared=null){
|
|||
|
|
const st=metaState(meta.id);if(!st.solved||st.expanded)return 0;
|
|||
|
|
await hydrateAdjacentMetas(meta);rebuildOccupancy();repairFacingGateConnections(meta);
|
|||
|
|
st.expanded=false;
|
|||
|
|
const retryRound=Math.max(0,Number.isInteger(st.expansionRetryRound)?st.expansionRetryRound:0);
|
|||
|
|
let made=0,attemptOffset=retryRound*37000017;
|
|||
|
|
if(prepared&&installPreparedChild(prepared))made++;
|
|||
|
|
const placeTargets=async frontiers=>{for(const frontier of frontiers){if(await placeChildAtFrontier(meta,frontier,attemptOffset))made++;attemptOffset+=1000}};
|
|||
|
|
await placeTargets(unresolvedExpansionCandidates(meta));
|
|||
|
|
for(const retryBase of[500000,900000,1400000]){const remaining=unresolvedExpansionCandidates(meta);if(!remaining.length)break;attemptOffset=Math.max(attemptOffset,retryBase);await placeTargets(remaining)}
|
|||
|
|
if(unsolvedBoardCount()===0)made+=await ensureUnsolvedFrontier(meta);
|
|||
|
|
const missingConnections=missingGateConnections(meta);
|
|||
|
|
st.expanded=missingConnections.length===0;st.expansionRetryRound=st.expanded?0:Math.min(1000000,retryRound+1);st.rev=nextRevision();markStateDirty(meta.id);
|
|||
|
|
const saved=await refreshWorldView({rebuild:false,syncConnections:true,hide:true,persist:true,immediate:true,lockHeld:true});await nextPaint();
|
|||
|
|
if(!saved)showStatus('\u76e4\u9762\u306f\u751f\u6210\u6e08\u307f\u3067\u3059\u3002\u4fdd\u5b58\u3092\u518d\u8a66\u884c\u3057\u3066\u304f\u3060\u3055\u3044\u3002',{retry:true,fresh:false});
|
|||
|
|
if(unsolvedBoardCount()===0)st.expanded=false;
|
|||
|
|
return made;
|
|||
|
|
}
|
|||
|
|
function expandMeta(meta,prepared=null){
|
|||
|
|
const id=meta?.id;
|
|||
|
|
return enqueueWorldMutation(async()=>{
|
|||
|
|
const current=data.metas[id];if(!current)return 0;
|
|||
|
|
if(!current.puzzle)await hydrateMeta(current);return expandMetaNow(current,prepared);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
async function repairMetaFrontierNow(meta){
|
|||
|
|
const st=metaState(meta.id);if(!st.solved||st.expanded)return 0;
|
|||
|
|
if(!meta.puzzle)await hydrateMeta(meta);await hydrateAdjacentMetas(meta);rebuildOccupancy();repairFacingGateConnections(meta);
|
|||
|
|
const frontiers=unresolvedExpansionCandidates(meta),round=Math.max(0,Number.isInteger(st.expansionRetryRound)?st.expansionRetryRound:0);
|
|||
|
|
let made=0;
|
|||
|
|
if(frontiers.length){
|
|||
|
|
const frontier=frontiers[round%frontiers.length],attemptBase=310000003+Math.imul(round+1,1000003);
|
|||
|
|
if(await placeChildAtFrontierAttempt(meta,frontier,attemptBase))made=1;
|
|||
|
|
}
|
|||
|
|
const missing=missingGateConnections(meta);st.expanded=missing.length===0;
|
|||
|
|
st.expansionRetryRound=st.expanded?0:Math.min(1000000,round+1);st.rev=nextRevision();markStateDirty(meta.id);return made;
|
|||
|
|
}
|
|||
|
|
function repairMetaFrontier(meta){
|
|||
|
|
const id=meta?.id;
|
|||
|
|
return enqueueWorldMutation(async()=>{const current=data.metas[id];return current?repairMetaFrontierNow(current):0});
|
|||
|
|
}
|
|||
|
|
function sharedExpansionRepairDelay(state,now=trustedNow()){
|
|||
|
|
if(!cloudAvailable||!data.cloudProfile||!state?.solved)return 0;const owner=state.solvedById,playerId=currentPlayerId();if(!owner||owner===playerId)return 0;
|
|||
|
|
const solvedAt=Number(state.solvedAt)||0;if(!solvedAt)return 0;const jitter=playerId?AppLogic.stableHash(['shared-expansion-takeover',playerId,owner,solvedAt])%SHARED_EXPANSION_JITTER_MS:0;return Math.max(0,solvedAt+SHARED_EXPANSION_GRACE_MS+jitter-now);
|
|||
|
|
}
|
|||
|
|
function nextSharedExpansionRepairDelay(){
|
|||
|
|
let wait=Infinity,found=false;for(const meta of Object.values(data.metas)){const st=metaState(meta.id);if(!st.solved||st.expanded)continue;found=true;wait=Math.min(wait,sharedExpansionRepairDelay(st))}return found?Math.max(0,wait):null;
|
|||
|
|
}
|
|||
|
|
function hasSharedExpansionRepairAuthority(){return Object.values(data.metas).some(meta=>{const st=metaState(meta.id);return st.solved&&!st.expanded&&sharedExpansionRepairDelay(st)<=0})}
|
|||
|
|
async function repairExpansions(){
|
|||
|
|
if(typeof fieldArchiveBusy!=='undefined'&&fieldArchiveBusy){scheduleExpansionRepair(1000);return 0}
|
|||
|
|
let made=0;
|
|||
|
|
for(let pass=0;pass<3;pass++){
|
|||
|
|
let passMade=0;const active=data.metas[activeBoard],pending=Object.values(data.metas).filter(meta=>{const st=metaState(meta.id);return st.solved&&!st.expanded&&sharedExpansionRepairDelay(st)<=0});
|
|||
|
|
pending.sort((left,right)=>{
|
|||
|
|
const leftDistance=active?Math.hypot(left.x-active.x,left.y-active.y):0,rightDistance=active?Math.hypot(right.x-active.x,right.y-active.y):0;
|
|||
|
|
return leftDistance-rightDistance||(metaState(left.id).expansionRetryRound||0)-(metaState(right.id).expansionRetryRound||0)||left.id.localeCompare(right.id,undefined,{numeric:true});
|
|||
|
|
});
|
|||
|
|
for(const meta of pending)passMade+=await repairMetaFrontier(meta)||0;
|
|||
|
|
made+=passMade;
|
|||
|
|
}
|
|||
|
|
let closedOffset=0;
|
|||
|
|
for(const{source,frontier}of closedVoidRepairCandidates().filter(({source})=>sharedExpansionRepairDelay(metaState(source.id))<=0).slice(0,2)){
|
|||
|
|
if(await placeChildAtFrontierAttempt(source,frontier,470000003+closedOffset*1000003))made++;
|
|||
|
|
closedOffset++;
|
|||
|
|
}
|
|||
|
|
if(unsolvedBoardCount()===0&&hasSharedExpansionRepairAuthority())made+=await ensureUnsolvedFrontier(data.metas.B0||Object.values(data.metas)[0]);
|
|||
|
|
await refreshWorldView({rebuild:true,syncConnections:true,hide:true,persist:true,immediate:true});if(pendingExpansionCount())scheduleExpansionRepair(700);return made;
|
|||
|
|
}
|
|||
|
|
let expansionRepairTimer=0;
|
|||
|
|
function scheduleExpansionRepair(delay=700){if(expansionRepairTimer)return;const sharedWait=nextSharedExpansionRepairDelay();if(sharedWait==null&&!pendingExpansionCount())return;expansionRepairTimer=setTimeout(()=>{expansionRepairTimer=0;void repairExpansions().catch(handleExpansionError)},Math.max(150,delay,sharedWait||0))}
|
|||
|
|
function reopenMissingGateExpansions(){
|
|||
|
|
rebuildOccupancy();let reopened=0;
|
|||
|
|
for(const meta of Object.values(data.metas)){
|
|||
|
|
const st=metaState(meta.id);if(!st.solved||!meta.puzzle)continue;
|
|||
|
|
repairFacingGateConnections(meta);
|
|||
|
|
if(st.expanded&&missingGateConnections(meta).length){st.expanded=false;st.rev=nextRevision();markStateDirty(meta.id);reopened++}
|
|||
|
|
}
|
|||
|
|
return reopened;
|
|||
|
|
}
|
|||
|
|
function pendingExpansionCount(){
|
|||
|
|
let count=0;
|
|||
|
|
for(const meta of Object.values(data.metas)){const st=metaState(meta.id);if(st.solved&&!st.expanded)count++}
|
|||
|
|
return count+closedVoidRepairCandidates().length;
|
|||
|
|
}
|
|||
|
|
function cellSet(p){return p._validSet||(p._validSet=new Set(p.valid.map(c=>ckey(...c))))}
|
|||
|
|
function warpMap(p){
|
|||
|
|
if(p._warpMap)return p._warpMap;const map=new Map();
|
|||
|
|
for(const pair of specialCellSet(p).warps){map.set(ckey(...pair.a),pair.b);map.set(ckey(...pair.b),pair.a)}
|
|||
|
|
return p._warpMap=map;
|
|||
|
|
}
|
|||
|
|
function warpPairForCell(p,cell){return cell?warpMap(p).get(ckey(...cell))||null:null}
|
|||
|
|
function isWarpTransition(p,a,b){const pair=warpPairForCell(p,a);return!!pair&&sameCell(pair,b)}
|
|||
|
|
function pathCellsAdjacent(p,a,b){return manhattan(a,b)===1||isWarpTransition(p,a,b)}
|
|||
|
|
function lockForDoor(p,cell){return specialCellSet(p).locks.find(lock=>sameCell(lock.door,cell))||null}
|
|||
|
|
function pathHasLockKey(path,lock){return!!lock&&path.cells.some(cell=>sameCell(cell,lock.key))}
|
|||
|
|
function crossingKeys(p){return specialCellSet(p).crossings.map(cell=>ckey(...cell))}
|
|||
|
|
function crossingsSatisfied(st,p){return crossingKeys(p).every(key=>Boolean(crossingStateAtCell(st,p,key)))}
|
|||
|
|
function pathIndexesAtCell(st,cellOrKey){
|
|||
|
|
const key=typeof cellOrKey==='string'?cellOrKey:ckey(...cellOrKey),indexes=[];
|
|||
|
|
st.paths.forEach((path,index)=>{if(path?.cells?.some(cell=>ckey(...cell)===key))indexes.push(index)});return indexes;
|
|||
|
|
}
|
|||
|
|
function crossingStateAtCell(st,p,cellOrKey){
|
|||
|
|
const key=typeof cellOrKey==='string'?cellOrKey:ckey(...cellOrKey);if(!crossingKeys(p).includes(key))return null;
|
|||
|
|
const cell=key.split(',').map(Number),entries=[];
|
|||
|
|
st.paths.forEach((path,index)=>{if(!path?.cells?.some(candidate=>sameCell(candidate,cell)))return;const axis=pathAxisAtCell(path,p,cell);if(axis)entries.push({index,axis})});
|
|||
|
|
if(entries.length!==2||entries[0].axis===entries[1].axis)return null;
|
|||
|
|
return{key,cell,horizontal:entries.find(entry=>entry.axis==='H')?.index,vertical:entries.find(entry=>entry.axis==='V')?.index};
|
|||
|
|
}
|
|||
|
|
function crossingExitDirection(st,p,pathIndex){
|
|||
|
|
const path=st.paths[pathIndex];if(!path?.cells||path.cells.length<2)return null;const cell=path.cells[path.cells.length-1],key=ckey(...cell);
|
|||
|
|
if(!crossingKeys(p).includes(key)||pathIndexesAtCell(st,key).length<2)return null;
|
|||
|
|
const previous=path.cells[path.cells.length-2];return[cell[0]-previous[0],cell[1]-previous[1]];
|
|||
|
|
}
|
|||
|
|
function gateObj(p,i){const g=p.g[i];return{i,cell:[g[0],g[1]],side:g[2]}}
|
|||
|
|
function outsidePoint(g){const[dR,dC]=SIDE_D[g.side];return[g.cell[0]+dR,g.cell[1]+dC]}
|
|||
|
|
function detachedTurnAnalysis(path,p){
|
|||
|
|
const cells=[],segments=[];let start=0;
|
|||
|
|
for(let index=1;index<path.cells.length;index++)if(isWarpTransition(p,path.cells[index-1],path.cells[index])){segments.push(path.cells.slice(start,index));start=index}
|
|||
|
|
segments.push(path.cells.slice(start));
|
|||
|
|
for(const segment of segments)for(let index=1;index<segment.length-1;index++){const previous=segment[index-1],cell=segment[index],next=segment[index+1],before=[cell[0]-previous[0],cell[1]-previous[1]],after=[next[0]-cell[0],next[1]-cell[1]];if(before[0]!==after[0]||before[1]!==after[1])cells.push(cell)}
|
|||
|
|
return{count:cells.length,cells};
|
|||
|
|
}
|
|||
|
|
function analyzeTurns(path,p,includeEnd){return path?.detachedStart?detachedTurnAnalysis(path,p):AppLogic.analyzePathTurns(path,p.g,specialCellSet(p).warps,includeEnd)}
|
|||
|
|
function turnAnalysis(path,p){return analyzeTurns(path,p,true)}
|
|||
|
|
function partialTurnCount(path,p){return analyzeTurns(path,p,false).count}
|
|||
|
|
function numbersForPath(path,p){return p.n.filter(n=>path.cells.some(c=>c[0]===n[0]&&c[1]===n[1])).map(n=>({cell:[n[0],n[1]],value:n[2]}))}
|
|||
|
|
function specialPathValid(path,p){
|
|||
|
|
for(const pair of specialCellSet(p).warps){
|
|||
|
|
const ai=path.cells.findIndex(cell=>sameCell(cell,pair.a)),bi=path.cells.findIndex(cell=>sameCell(cell,pair.b));
|
|||
|
|
if((ai>=0)!==(bi>=0)||ai>=0&&Math.abs(ai-bi)!==1)return false;
|
|||
|
|
}
|
|||
|
|
for(const lock of specialCellSet(p).locks){
|
|||
|
|
const keyIndex=path.cells.findIndex(cell=>sameCell(cell,lock.key)),doorIndex=path.cells.findIndex(cell=>sameCell(cell,lock.door));
|
|||
|
|
if(doorIndex>=0&&(keyIndex<0||keyIndex>doorIndex))return false;
|
|||
|
|
}
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
function pathValid(path,p){const nums=numbersForPath(path,p),ta=turnAnalysis(path,p);return specialPathValid(path,p)&&nums.length===1&&nums[0].value===ta.count&&ta.cells.some(c=>sameCell(c,nums[0].cell))}
|
|||
|
|
function usedGateSet(st,except=-1){const s=new Set();st.paths.forEach((p,i)=>{if(i===except||!p)return;if(!p.detachedStart)s.add(p.startGate);if(p.endGate!=null)s.add(p.endGate);if(p.openGate!=null)s.add(p.openGate)});return s}
|
|||
|
|
function occupiedMap(st){const m=new Map();st.paths.forEach((p,pi)=>p.cells.forEach(c=>m.set(ckey(...c),pi)));return m}
|
|||
|
|
function isSolved(st,p){if(!crossingsSatisfied(st,p)||st.paths.length!==p.n.length||st.paths.some(x=>x.endGate==null||x.detachedStart))return false;if(usedGateSet(st).size!==p.g.length)return false;if(occupiedMap(st).size!==p.valid.length)return false;return st.paths.every(x=>pathValid(x,p))}
|
|||
|
|
function sanitizeStateForPuzzle(meta,{quiet=false}={}){
|
|||
|
|
const p=puzzleOf(meta),st=metaState(meta.id),validCells=cellSet(p),crossingSet=new Set(crossingKeys(p)),warpKeys=new Set([...warpMap(p).keys()]),usedCells=new Map(),usedGates=new Set(),kept=[];
|
|||
|
|
let removed=0;
|
|||
|
|
for(const rawPath of st.paths){
|
|||
|
|
const path=normalizePath(rawPath);
|
|||
|
|
let valid=!!path&&path.startGate>=0&&path.startGate<p.g.length&&path.endGate!==path.startGate&&(!path.detachedStart||path.endGate==null);
|
|||
|
|
if(valid&&path.endGate!=null)valid=path.endGate>=0&&path.endGate<p.g.length;
|
|||
|
|
if(valid){
|
|||
|
|
const start=p.g[path.startGate];valid=path.detachedStart||sameCell(path.cells[0],start);
|
|||
|
|
if(path.endGate!=null)valid=valid&&sameCell(path.cells[path.cells.length-1],p.g[path.endGate]);
|
|||
|
|
}
|
|||
|
|
const ownCells=new Set();
|
|||
|
|
if(valid)for(let i=0;i<path.cells.length;i++){
|
|||
|
|
const cell=path.cells[i],key=ckey(...cell),owners=usedCells.get(key)||0;
|
|||
|
|
if(!validCells.has(key)||ownCells.has(key)||(owners>0&&!crossingSet.has(key))||owners>=2||(i>0&&!pathCellsAdjacent(p,path.cells[i-1],cell))){valid=false;break}
|
|||
|
|
ownCells.add(key);
|
|||
|
|
}
|
|||
|
|
if(valid&&path.detachedStart&&path.endGate==null&&path.cells.length>1&&warpKeys.has(ckey(...path.cells[0]))&&warpKeys.has(ckey(...path.cells[path.cells.length-1])))valid=false;
|
|||
|
|
if(valid&&(!path.detachedStart&&usedGates.has(path.startGate)||path.endGate!=null&&usedGates.has(path.endGate)||path.openGate!=null&&usedGates.has(path.openGate)))valid=false;
|
|||
|
|
if(!valid){removed++;continue}
|
|||
|
|
for(const key of ownCells)usedCells.set(key,(usedCells.get(key)||0)+1);
|
|||
|
|
if(!path.detachedStart)usedGates.add(path.startGate);if(path.endGate!=null)usedGates.add(path.endGate);if(path.openGate!=null)usedGates.add(path.openGate);
|
|||
|
|
Object.assign(rawPath,path);kept.push(rawPath);
|
|||
|
|
}
|
|||
|
|
if(removed){st.paths.splice(0,st.paths.length,...kept);st.rev=nextRevision();markStateDirty(meta.id);if(!quiet)loadNotices.push(`${meta.id}\u306e\u7834\u640d\u3057\u305f\u7dda\u3092${removed}\u672c\u524a\u9664\u3057\u307e\u3057\u305f\u3002`)}
|
|||
|
|
if(st.specialProgress?.crossings?.length){st.specialProgress={crossings:[]};st.rev=nextRevision();markStateDirty(meta.id)}
|
|||
|
|
const solved=isSolved(st,p);
|
|||
|
|
if(st.solved!==solved){st.solved=solved;st.rev=nextRevision();markStateDirty(meta.id)}
|
|||
|
|
if(solved&&!st.solvedBy){st.solvedBy=LEGACY_LOCAL_SOLVER;st.rev=nextRevision();markStateDirty(meta.id)}
|
|||
|
|
if(!solved&&(st.solvedBy||st.scoreAwarded||st.store)){st.solvedBy=null;st.scoreAwarded=0;st.store=null;st.rev=nextRevision();markStateDirty(meta.id)}
|
|||
|
|
return removed;
|
|||
|
|
}
|
|||
|
|
function chooseColor(meta,gi){const inherited=neighborColor(meta,gi);if(inherited!=null)return inherited;const used=new Set(metaState(meta.id).paths.map(p=>p.colorIndex).filter(v=>v!=null));for(let i=0;i<LINE_COLORS.length;i++)if(!used.has(i))return i;return gi%10}
|
|||
|
|
function pathColor(path,index=0){return LINE_COLORS[(path.colorIndex??index)%10]}
|
|||
|
|
function hexRgb(hex){const value=parseInt(String(hex).replace('#',''),16);return[(value>>16)&255,(value>>8)&255,value&255]}
|
|||
|
|
function mixHex(a,b,t){const aa=hexRgb(a),bb=hexRgb(b),q=Math.max(0,Math.min(1,t)),value=aa.map((v,i)=>Math.round(v+(bb[i]-v)*q));return`#${value.map(v=>v.toString(16).padStart(2,'0')).join('')}`}
|
|||
|
|
function levelBackground(level){const t=(Math.max(1,Math.min(10,level||1))-1)/9;return[ mixHex(DIFF_BACKGROUND_EASY[0],DIFF_BACKGROUND_HARD[0],t),mixHex(DIFF_BACKGROUND_EASY[1],DIFF_BACKGROUND_HARD[1],t) ]}
|
|||
|
|
function pathDistanceProfile(path,p){
|
|||
|
|
const samples=[];let total=0,previousPoint=path.detachedStart?boardCellCenter(path.cells[0]):gatePoint(gateObj(p,path.startGate)),previousCell=null;
|
|||
|
|
for(const cell of path.cells||[]){
|
|||
|
|
const point=boardCellCenter(cell);
|
|||
|
|
if(!previousCell||!isWarpTransition(p,previousCell,cell))total+=Math.hypot(point[0]-previousPoint[0],point[1]-previousPoint[1]);
|
|||
|
|
samples.push({cell,distance:total});previousPoint=point;previousCell=cell;
|
|||
|
|
}
|
|||
|
|
if(path.endGate!=null){const end=gatePoint(gateObj(p,path.endGate));total+=Math.hypot(end[0]-previousPoint[0],end[1]-previousPoint[1])}
|
|||
|
|
return{samples,total};
|
|||
|
|
}
|
|||
|
|
function pathProgressAtCell(path,p,cell){
|
|||
|
|
const profile=pathDistanceProfile(path,p),sample=profile.samples.find(entry=>sameCell(entry.cell,cell));
|
|||
|
|
return sample&&profile.total>0?Math.max(0,Math.min(1,sample.distance/profile.total)):0;
|
|||
|
|
}
|
|||
|
|
function pathColorAtCell(path,p,cell){
|
|||
|
|
const startIndex=path.startColorIndex??path.colorIndex??0,endIndex=path.endColorIndex??startIndex;
|
|||
|
|
const colorEndGate=path.endGate??path.openGate;if(uiSettings.lightweightRendering||colorEndGate==null||startIndex===endIndex)return LINE_COLORS[startIndex%LINE_COLORS.length];
|
|||
|
|
return mixHex(LINE_COLORS[startIndex%LINE_COLORS.length],LINE_COLORS[endIndex%LINE_COLORS.length],pathProgressAtCell(path,p,cell));
|
|||
|
|
}
|
|||
|
|
function straightNumberWarningKeys(st,p){
|
|||
|
|
const numberKeys=new Set(p.n.map(number=>ckey(number[0],number[1]))),warnings=new Set();
|
|||
|
|
for(const path of st.paths){
|
|||
|
|
if(!path.cells.length)continue;
|
|||
|
|
const points=[...(path.detachedStart?[]:[outsidePoint(gateObj(p,path.startGate))]),...path.cells],offset=path.detachedStart?0:1;
|
|||
|
|
if(path.endGate!=null)points.push(outsidePoint(gateObj(p,path.endGate)));
|
|||
|
|
path.cells.forEach((cell,index)=>{
|
|||
|
|
const key=ckey(...cell),pointIndex=index+offset;
|
|||
|
|
if(!numberKeys.has(key)||!points[pointIndex-1]||!points[pointIndex+1])return;
|
|||
|
|
const before=[points[pointIndex][0]-points[pointIndex-1][0],points[pointIndex][1]-points[pointIndex-1][1]],
|
|||
|
|
after=[points[pointIndex+1][0]-points[pointIndex][0],points[pointIndex+1][1]-points[pointIndex][1]];
|
|||
|
|
if(before[0]===after[0]&&before[1]===after[1])warnings.add(key);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
return warnings;
|
|||
|
|
}
|
|||
|
|
function multipleNumberWarningKeys(st,p){
|
|||
|
|
const numberKeys=new Set(p.n.map(number=>ckey(number[0],number[1]))),warnings=new Set();
|
|||
|
|
for(const path of st.paths){
|
|||
|
|
const hits=path.cells.map(cell=>ckey(...cell)).filter(key=>numberKeys.has(key));
|
|||
|
|
if(hits.length>1)for(const key of hits)warnings.add(key);
|
|||
|
|
}
|
|||
|
|
return warnings;
|
|||
|
|
}
|
|||
|
|
function formatScore(value){return Math.max(0,Math.round(Number(value)||0)).toLocaleString('en-US')}
|
|||
|
|
function roundToThreeSignificantDigits(value){const amount=Math.max(0,Number(value)||0);if(amount<1000)return Math.round(amount);const unit=10**Math.max(0,Math.floor(Math.log10(amount))-2);return Math.round(amount/unit)*unit}
|
|||
|
|
function formatProjectedScore(value){return formatScore(roundToThreeSignificantDigits(value))}
|
|||
|
|
function lineGraphEnvironment(){return{data,metaState,matchingNeighborGate}}
|
|||
|
|
function collectConnectedLineComponent(meta,pathIndex,cache=null){return AppLogic.collectConnectedLineComponent(meta,pathIndex,cache||currentLineGraphCaches().components,lineGraphEnvironment())}
|
|||
|
|
function connectedLineLength(meta,pathIndex,cache=null){return AppLogic.connectedLineLength(meta,pathIndex,cache||currentLineGraphCaches().components,lineGraphEnvironment())}
|
|||
|
|
function renderedConnectedLineWidth(meta,pathIndex,cache=null,componentCache=null){const caches=currentLineGraphCaches();return AppLogic.renderedConnectedLineWidth(meta,pathIndex,cache||caches.widths,lineGraphEnvironment(),componentCache||caches.components)}
|
|||
|
|
function minimapGeometryForComponent(component,cache=currentLineGraphCaches().geometries){
|
|||
|
|
if(cache.has(component))return cache.get(component);
|
|||
|
|
const segments=[];
|
|||
|
|
for(const[member,memberIndex]of component.members||[]){
|
|||
|
|
if(!member?.puzzle)continue;const memberPath=metaState(member.id).paths[memberIndex];if(!memberPath?.cells?.length)continue;const memberPuzzle=puzzleOf(member),
|
|||
|
|
gateMap=gateIndex=>{const g=gateObj(memberPuzzle,gateIndex),row=g.cell[0],col=g.cell[1];return[member.x+(col+(g.side==='E'?1:g.side==='W'?0:.5))/CHUNK,member.y+(row+(g.side==='S'?1:g.side==='N'?0:.5))/CHUNK]},turns=[];
|
|||
|
|
for(let pointIndex=0;pointIndex<memberPath.cells.length;pointIndex++){const cell=memberPath.cells[pointIndex],previous=memberPath.cells[pointIndex-1],next=memberPath.cells[pointIndex+1];if(!previous||!next||(cell[1]-previous[1])*(next[0]-cell[0])!==(cell[0]-previous[0])*(next[1]-cell[1]))turns.push([member.x+(cell[1]+.5)/CHUNK,member.y+(cell[0]+.5)/CHUNK])}
|
|||
|
|
const points=[gateMap(memberPath.startGate),...turns];if(memberPath.endGate!=null)points.push(gateMap(memberPath.endGate));segments.push({memberId:member.id,pathIndex:memberIndex,points,color:pathColor(memberPath,memberIndex)});
|
|||
|
|
}
|
|||
|
|
const geometry={length:component.length,segments};cache.set(component,geometry);perfCount('minimapGeometryBuilds');return geometry;
|
|||
|
|
}
|
|||
|
|
function scheduleMinimap(markDirty=false){
|
|||
|
|
if(markDirty)minimapDirty=true;
|
|||
|
|
if(!minimapCanvas||!minimapDirty||minimapFrame)return;
|
|||
|
|
// Panning never needs a live minimap repaint. The viewport indicator is
|
|||
|
|
// refreshed once when the gesture ends, avoiding a second map render loop.
|
|||
|
|
if(pan||pinch||globalThis.document?.body?.classList?.contains?.('is-interacting'))return;
|
|||
|
|
const step=timestamp=>{
|
|||
|
|
if(!minimapFrame)return;
|
|||
|
|
if(globalThis.document?.body?.classList?.contains?.('is-interacting')){minimapFrame=0;return}
|
|||
|
|
if(timestamp-minimapLastDraw<AUXILIARY_FRAME_INTERVAL-1){minimapFrame=requestAnimationFrame(step);return}
|
|||
|
|
minimapFrame=0;minimapLastDraw=timestamp;markVisualFrame(timestamp);drawMinimap();
|
|||
|
|
};
|
|||
|
|
minimapFrame=requestAnimationFrame(step);
|
|||
|
|
}
|
|||
|
|
function cameraCenterInChunks(){
|
|||
|
|
const r=getViewportRect();
|
|||
|
|
return[renderOriginX+(r.width/2-cam.x)/(UNIT*cam.scale),renderOriginY+(r.height/2-cam.y)/(UNIT*cam.scale)];
|
|||
|
|
}
|
|||
|
|
function drawMapBoardCells(context,metas,mapX,mapY,unitSize,{showLevels=false}={}){
|
|||
|
|
const overlap=Math.max(.35,Math.min(1,unitSize*.08)),unsolvedRects=[],solvedRects=[],levelLabels=[];
|
|||
|
|
for(const meta of metas){
|
|||
|
|
const solved=metaState(meta.id).solved,rects=solved?solvedRects:unsolvedRects;let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
|
|||
|
|
for(const[dx,dy]of meta.chunks){const worldX=meta.x+dx,worldY=meta.y+dy,x=mapX(worldX)-overlap/2,y=mapY(worldY)-overlap/2;rects.push([x,y,Math.max(1,unitSize+overlap),Math.max(1,unitSize+overlap)]);minX=Math.min(minX,worldX);minY=Math.min(minY,worldY);maxX=Math.max(maxX,worldX+1);maxY=Math.max(maxY,worldY+1)}
|
|||
|
|
if(showLevels&&!solved&&minX<Infinity)levelLabels.push({text:String(Math.max(1,Math.min(10,Number(meta.level)||1))),x:mapX((minX+maxX)/2),y:mapY((minY+maxY)/2)});
|
|||
|
|
}
|
|||
|
|
const fillRects=(rects,color)=>{if(!rects.length)return;context.beginPath();for(const[x,y,w,h]of rects)context.rect(x,y,w,h);context.fillStyle=color;context.fill()};
|
|||
|
|
fillRects(unsolvedRects,'#30373c');fillRects(solvedRects,'#477a5d');
|
|||
|
|
if(showLevels&&levelLabels.length){const fontSize=Math.max(8,Math.min(22,unitSize*.42));context.save();context.font=`900 ${fontSize}px DotGothic16Local,monospace`;context.textAlign='center';context.textBaseline='middle';context.lineJoin='round';context.lineWidth=Math.max(2,fontSize*.18);context.strokeStyle='rgba(7,10,12,.88)';context.fillStyle='rgba(245,249,252,.96)';for(const label of levelLabels){context.strokeText(label.text,label.x,label.y);context.fillText(label.text,label.x,label.y)}context.restore()}
|
|||
|
|
}
|
|||
|
|
function drawMapLongLines(context,metas,visibleIds,mapX,mapY,lineWidth=1){
|
|||
|
|
if(metas.length>96)return 0;let longSegments=0;const caches=currentLineGraphCaches(),seenComponents=new Set();context.lineCap='square';context.lineJoin='miter';
|
|||
|
|
for(const meta of metas){if(!meta.puzzle)continue;const st=metaState(meta.id);for(let index=0;index<st.paths.length;index++){
|
|||
|
|
const component=collectConnectedLineComponent(meta,index,caches.components);if(component.length<=MINIMAP_LONG_LINE||seenComponents.has(component))continue;seenComponents.add(component);longSegments++;
|
|||
|
|
const geometry=minimapGeometryForComponent(component,caches.geometries);for(const segment of geometry.segments){if(!visibleIds.has(segment.memberId))continue;context.beginPath();segment.points.forEach((point,i)=>(i?context.lineTo(mapX(point[0]),mapY(point[1])):context.moveTo(mapX(point[0]),mapY(point[1]))));context.strokeStyle=segment.color;context.lineWidth=lineWidth;context.globalAlpha=.9;context.stroke();context.globalAlpha=1}
|
|||
|
|
}}return longSegments
|
|||
|
|
}
|
|||
|
|
function rebuildMinimapWorld(width,height,dpr,centerX,centerY){
|
|||
|
|
const started=perfStart(),spanX=MINIMAP_VIEW_CHUNKS_X,spanY=Math.max(10,spanX*height/Math.max(1,width)),scale=Math.min(width/spanX,height/spanY),overscan=MINIMAP_CACHE_OVERSCAN_CHUNKS,overscanPixels=overscan*scale,
|
|||
|
|
baseWidth=width+overscanPixels*2,baseHeight=height+overscanPixels*2,minChunkX=centerX-spanX/2-overscan,minChunkY=centerY-spanY/2-overscan,maxChunkX=centerX+spanX/2+overscan,maxChunkY=centerY+spanY/2+overscan,
|
|||
|
|
mapX=value=>baseWidth/2+(value-centerX)*scale,mapY=value=>baseHeight/2+(value-centerY)*scale,pixelWidth=Math.round(baseWidth*dpr),pixelHeight=Math.round(baseHeight*dpr);
|
|||
|
|
if(minimapBase.width!==pixelWidth||minimapBase.height!==pixelHeight){minimapBase.width=pixelWidth;minimapBase.height=pixelHeight}
|
|||
|
|
const base=minimapBase.getContext('2d');base.setTransform(dpr,0,0,dpr,0,0);base.clearRect(0,0,baseWidth,baseHeight);base.fillStyle='#101315';base.fillRect(0,0,baseWidth,baseHeight);
|
|||
|
|
const visibleIds=new Set();for(let y=Math.floor(minChunkY)-1;y<=Math.ceil(maxChunkY)+1;y++)for(let x=Math.floor(minChunkX)-1;x<=Math.ceil(maxChunkX)+1;x++){const id=occupancy.get(key2(x,y));if(id)visibleIds.add(id)}
|
|||
|
|
const visibleMetas=[...visibleIds].map(id=>data.metas[id]).filter(Boolean);drawMapBoardCells(base,visibleMetas,mapX,mapY,scale);const longSegments=drawMapLongLines(base,visibleMetas,visibleIds,mapX,mapY,1);
|
|||
|
|
minimapCache={revision:minimapWorldRevision,width,height,dpr,anchorX:centerX,anchorY:centerY,scale,overscanPixels,baseWidth,baseHeight,longSegments};perfCount('minimapWorldBuilds');perfEnd('rebuildMinimapWorld',started);
|
|||
|
|
}
|
|||
|
|
function drawMinimap(){
|
|||
|
|
if(globalThis.document?.body?.classList?.contains?.('is-interacting')){minimapDirty=true;perfCount('minimapDrawsDeferredDuringInteraction');return false}
|
|||
|
|
const started=perfStart();perfCount('minimapDraws');const rect=getMinimapRect(),width=Math.max(1,Math.round(rect.width)),height=Math.max(1,Math.round(rect.height)),dpr=Math.min(2,Math.max(1,window.devicePixelRatio||1)),pixelWidth=Math.round(width*dpr),pixelHeight=Math.round(height*dpr),[centerX,centerY]=cameraCenterInChunks();
|
|||
|
|
if(minimapCanvas.width!==pixelWidth||minimapCanvas.height!==pixelHeight){minimapCanvas.width=pixelWidth;minimapCanvas.height=pixelHeight}
|
|||
|
|
const stale=!minimapCache||minimapCache.revision!==minimapWorldRevision||minimapCache.width!==width||minimapCache.height!==height||minimapCache.dpr!==dpr||
|
|||
|
|
Math.abs(centerX-minimapCache.anchorX)*minimapCache.scale>minimapCache.overscanPixels*.75||Math.abs(centerY-minimapCache.anchorY)*minimapCache.scale>minimapCache.overscanPixels*.75;
|
|||
|
|
if(stale)rebuildMinimapWorld(width,height,dpr,centerX,centerY);
|
|||
|
|
minimapDirty=false;minimapLongSegments=minimapCache.longSegments;
|
|||
|
|
const sourceX=(minimapCache.overscanPixels+(centerX-minimapCache.anchorX)*minimapCache.scale)*dpr,sourceY=(minimapCache.overscanPixels+(centerY-minimapCache.anchorY)*minimapCache.scale)*dpr,
|
|||
|
|
context=minimapCanvas.getContext('2d');context.setTransform(1,0,0,1,0,0);context.clearRect(0,0,pixelWidth,pixelHeight);context.drawImage(minimapBase,sourceX,sourceY,pixelWidth,pixelHeight,0,0,pixelWidth,pixelHeight);
|
|||
|
|
context.setTransform(dpr,0,0,dpr,0,0);context.strokeStyle='rgba(239,248,255,.82)';context.lineWidth=1;context.beginPath();context.moveTo(width/2-5,height/2);context.lineTo(width/2+5,height/2);context.moveTo(width/2,height/2-5);context.lineTo(width/2,height/2+5);context.stroke();
|
|||
|
|
let nearbyPlayers=0;for(const player of(typeof remotePlayers==='undefined'?[]:remotePlayers.values())){const worldX=Number.isFinite(player.currentX)?player.currentX:player.targetX,worldY=Number.isFinite(player.currentY)?player.currentY:player.targetY;if(!Number.isFinite(worldX)||!Number.isFinite(worldY))continue;const x=width/2+(worldX-centerX)*minimapCache.scale,y=height/2+(worldY-centerY)*minimapCache.scale;if(x<-4||y<-4||x>width+4||y>height+4)continue;nearbyPlayers++;context.beginPath();context.arc(x,y,2.6,0,Math.PI*2);context.fillStyle='#d9f06f';context.fill();context.strokeStyle='rgba(8,12,15,.9)';context.lineWidth=1;context.stroke()}
|
|||
|
|
const spanX=MINIMAP_VIEW_CHUNKS_X,spanY=Math.max(10,spanX*height/Math.max(1,width)),bounds={minX:Math.floor(centerX-spanX/2)-1,maxX:Math.ceil(centerX+spanX/2)+1,minY:Math.floor(centerY-spanY/2)-1,maxY:Math.ceil(centerY+spanY/2)+1},visibleIds=visibleMetaIdsForBounds(bounds),visibleMetas=[...visibleIds].map(id=>data.metas[id]).filter(Boolean),
|
|||
|
|
solved=visibleMetas.reduce((count,meta)=>count+(metaState(meta.id).solved?1:0),0),status=`${solved} / ${visibleMetas.length} · ${nearbyPlayers+1}人`,label=`\u8996\u70b9\u5468\u8fba\u306e\u76e4\u9762\u3002\u30af\u30ea\u30a2\u6e08\u307f\uff1a${solved}\u3001\u672a\u30af\u30ea\u30a2\uff1a${visibleMetas.length-solved}\u3001\u8fd1\u304f\u306e\u30d7\u30ec\u30a4\u30e4\u30fc\uff1a${nearbyPlayers+1}\u4eba\u3002`;
|
|||
|
|
if(minimapStatus.textContent!==status)minimapStatus.textContent=status;if(minimapCanvas.getAttribute('aria-label')!==label)minimapCanvas.setAttribute('aria-label',label);
|
|||
|
|
perfCount(stale?'minimapCacheMisses':'minimapCacheHits');perfEnd('drawMinimap',started);
|
|||
|
|
}
|
|||
|
|
function worldNavigationBounds(){
|
|||
|
|
const bounds=fieldBoundsFromMetas(data.metas),margin=3;return{minX:bounds.minX-margin,maxX:bounds.maxX+margin,minY:bounds.minY-margin,maxY:bounds.maxY+margin};
|
|||
|
|
}
|
|||
|
|
function centerWorldUnit(worldX,worldY,{persist=true}={}){
|
|||
|
|
if(!Number.isFinite(worldX)||!Number.isFinite(worldY))return false;const bounds=worldNavigationBounds(),rect=getViewportRect();
|
|||
|
|
worldX=Math.max(bounds.minX,Math.min(bounds.maxX,worldX));worldY=Math.max(bounds.minY,Math.min(bounds.maxY,worldY));
|
|||
|
|
worldOverviewActive=false;cam.x=rect.width/2-(worldX-renderOriginX)*UNIT*cam.scale;cam.y=rect.height/2-(worldY-renderOriginY)*UNIT*cam.scale;applyCamera(true);ensureBoards();if(persist)recordCameraAnchor();return true;
|
|||
|
|
}
|
|||
|
|
function minimapPoint(event){
|
|||
|
|
const rect=getMinimapRect();return{x:event.clientX-rect.left,y:event.clientY-rect.top,width:rect.width,height:rect.height};
|
|||
|
|
}
|
|||
|
|
function minimapWorldPoint(event){
|
|||
|
|
const point=minimapPoint(event),center=cameraCenterInChunks(),scale=minimapCache?.scale||Math.max(1,point.width/MINIMAP_VIEW_CHUNKS_X);
|
|||
|
|
return{x:center[0]+(point.x-point.width/2)/scale,y:center[1]+(point.y-point.height/2)/scale,scale};
|
|||
|
|
}
|
|||
|
|
function beginMinimapPointer(event){
|
|||
|
|
if(event.button!==0)return;event.preventDefault();event.stopPropagation();const target=minimapWorldPoint(event);
|
|||
|
|
minimapPointerState={id:event.pointerId,startClientX:event.clientX,startClientY:event.clientY,targetX:target.x,targetY:target.y,scale:target.scale};
|
|||
|
|
try{minimapCanvas.setPointerCapture(event.pointerId)}catch(_){}
|
|||
|
|
centerWorldUnit(target.x,target.y,{persist:false});minimapCanvas.focus({preventScroll:true});
|
|||
|
|
}
|
|||
|
|
function moveMinimapPointer(event){
|
|||
|
|
const state=minimapPointerState;if(!state||state.id!==event.pointerId)return;event.preventDefault();
|
|||
|
|
centerWorldUnit(state.targetX+(event.clientX-state.startClientX)/state.scale,state.targetY+(event.clientY-state.startClientY)/state.scale,{persist:false});
|
|||
|
|
}
|
|||
|
|
function endMinimapPointer(event){
|
|||
|
|
if(!minimapPointerState||minimapPointerState.id!==event.pointerId)return;minimapPointerState=null;
|
|||
|
|
try{if(minimapCanvas.hasPointerCapture?.(event.pointerId))minimapCanvas.releasePointerCapture(event.pointerId)}catch(_){}
|
|||
|
|
recordCameraAnchor();
|
|||
|
|
}
|
|||
|
|
function keyMinimap(event){
|
|||
|
|
const directions={ArrowUp:[0,-1],ArrowDown:[0,1],ArrowLeft:[-1,0],ArrowRight:[1,0]},direction=directions[event.key];
|
|||
|
|
if(!direction)return;event.preventDefault();const center=cameraCenterInChunks(),step=event.shiftKey?5:1;centerWorldUnit(center[0]+direction[0]*step,center[1]+direction[1]*step);
|
|||
|
|
}
|
|||
|
|
function scoreFromThickness(meta,totalThickness){
|
|||
|
|
const level=Math.max(1,Math.min(10,meta.level||1)),
|
|||
|
|
sections=Math.max(1,meta.chunks?.length||Math.ceil((((meta.puzzle?.valid?.length||0)+(meta.puzzle?.obstacles?.length||0))||25)/25)),
|
|||
|
|
hardMultiplier=1+Math.max(0,level-4)*.18,
|
|||
|
|
largeMultiplier=1+Math.pow(Math.max(0,sections-1),.82)*(.18+level*.018),
|
|||
|
|
effortMultiplier=hardMultiplier*largeMultiplier,
|
|||
|
|
raw=level*level*100+totalThickness*level*20,
|
|||
|
|
originalAward=Math.max(100,Math.round(raw/10)*10),
|
|||
|
|
quarter=Math.max(25,Math.round(originalAward/4)),
|
|||
|
|
halved=Math.max(13,Math.round(quarter/2));
|
|||
|
|
return Math.min(MAX_SCORE,Math.max(13,Math.round(halved*effortMultiplier)));
|
|||
|
|
}
|
|||
|
|
function scoreForBoard(meta){
|
|||
|
|
const st=metaState(meta.id);
|
|||
|
|
if(!st.solved||!st.paths.length)return 0;
|
|||
|
|
const cache=new Map(),totalThickness=st.paths.reduce((sum,_,index)=>sum+lineStrokeWidth(connectedLineLength(meta,index,cache)),0);
|
|||
|
|
return scoreFromThickness(meta,totalThickness);
|
|||
|
|
}
|
|||
|
|
function scoreLensCountForMeta(){return activeScoreLensCount()}
|
|||
|
|
function timeAttackRewardModifier(){
|
|||
|
|
const run=data.timeAttack;if(!run||trustedNow()>=run.endsAt)return 1;
|
|||
|
|
return timeAttackMultiplier(run.baseCollected||0);
|
|||
|
|
}
|
|||
|
|
function rewardDetailsForBoard(meta,state=metaState(meta.id)){
|
|||
|
|
const baseScore=scoreForBoard(meta),options={worldSeed:worldSeedValue(),meta,state,scoreLensCount:scoreLensCountForMeta(meta)},
|
|||
|
|
beforeTime=AppLogic.deterministicBoardReward(baseScore,{...options,timeAttackModifier:1}),
|
|||
|
|
reward=AppLogic.deterministicBoardReward(baseScore,{...options,timeAttackModifier:timeAttackRewardModifier()});
|
|||
|
|
return{...reward,preTimeAward:beforeTime.award};
|
|||
|
|
}
|
|||
|
|
function projectedScoreForBoard(meta){
|
|||
|
|
const solution=puzzleOf(meta).solution||[],baseScore=scoreFromThickness(meta,solution.reduce((sum,path)=>sum+lineStrokeWidth(path.cells.length),0)),
|
|||
|
|
projectedState={paths:solution,solved:true};
|
|||
|
|
return AppLogic.deterministicBoardReward(baseScore,{worldSeed:worldSeedValue(),meta,state:projectedState,timeAttackModifier:timeAttackRewardModifier(),scoreLensCount:scoreLensCountForMeta(meta)}).award;
|
|||
|
|
}
|
|||
|
|
function maybeOpenStore(meta,st,solveAward,roll=null){
|
|||
|
|
const deterministicRoll=roll==null?(hash32(meta.seed^0x7f4a7c15)>>>0)/4294967296:roll;
|
|||
|
|
if(!st.solved||st.store||!st.paths.length||deterministicRoll>=STORE_CHANCE)return 0;
|
|||
|
|
const pathIndex=st.paths.reduce((best,path,index)=>path.cells.length>st.paths[best].cells.length?index:best,0),
|
|||
|
|
path=st.paths[pathIndex],
|
|||
|
|
cellIndex=Math.max(0,Math.min(path.cells.length-1,Math.floor(path.cells.length*.55))),
|
|||
|
|
bonus=0;
|
|||
|
|
st.store={owner:currentPlayerName(),pathIndex,cellIndex,openedAt:trustedNow(),priceVersion:STORE_PRICE_VERSION,priceCoefficient:null,bonus,bonusVersion:SCORE_VERSION,itemIds:seededStoreItemIds(meta.seed),purchases:[]};
|
|||
|
|
st.store.priceCoefficient=storePriceDetails(meta,st.store).coefficient;
|
|||
|
|
|
|||
|
|
return bonus;
|
|||
|
|
}
|
|||
|
|
function gatePoint(g){const r=g.cell[0],c=g.cell[1],x=PAD+(c+.5)*CELL,y=PAD+(r+.5)*CELL;return g.side==='N'?[x,PAD+r*CELL]:g.side==='S'?[x,PAD+(r+1)*CELL]:g.side==='W'?[PAD+c*CELL,y]:[PAD+(c+1)*CELL,y]}
|
|||
|
|
function gateMarkerPathAt(point,side,sealed=false,scale=1){
|
|||
|
|
const[dr,dc]=SIDE_D[side],ox=dc,oy=dr,tx=-dr,ty=dc,stem=11*scale,back=4.5*scale,wing=4.5*scale,
|
|||
|
|
tip=[point[0]+ox*stem,point[1]+oy*stem];
|
|||
|
|
if(sealed){
|
|||
|
|
const center=[point[0]+ox*7*scale,point[1]+oy*7*scale],
|
|||
|
|
left=[center[0]+tx*wing,center[1]+ty*wing],right=[center[0]-tx*wing,center[1]-ty*wing];
|
|||
|
|
return`M ${point[0]} ${point[1]} L ${center[0]} ${center[1]} M ${left[0]} ${left[1]} L ${right[0]} ${right[1]}`;
|
|||
|
|
}
|
|||
|
|
const base=[tip[0]-ox*back,tip[1]-oy*back],
|
|||
|
|
left=[base[0]+tx*wing,base[1]+ty*wing],right=[base[0]-tx*wing,base[1]-ty*wing];
|
|||
|
|
return`M ${point[0]} ${point[1]} L ${tip[0]} ${tip[1]} M ${left[0]} ${left[1]} L ${tip[0]} ${tip[1]} L ${right[0]} ${right[1]}`;
|
|||
|
|
}
|
|||
|
|
function gateHitBox(point,side){
|
|||
|
|
// Keep hit regions inside the owning board. Outward hit boxes from adjacent
|
|||
|
|
// boards overlapped and could steal a pointer from the opposite gate.
|
|||
|
|
const inward=Math.round(CELL*.42),outward=0,half=Math.round(CELL*.30),depth=inward+outward;
|
|||
|
|
if(side==='N')return{x:point[0]-half,y:point[1],width:half*2,height:depth};
|
|||
|
|
if(side==='S')return{x:point[0]-half,y:point[1]-inward,width:half*2,height:depth};
|
|||
|
|
if(side==='W')return{x:point[0],y:point[1]-half,width:depth,height:half*2};
|
|||
|
|
return{x:point[0]-inward,y:point[1]-half,width:depth,height:half*2};
|
|||
|
|
}
|
|||
|
|
function gateAtCell(p,cell,exclude=null,side=null){if(!p._gateCells){p._gateCells=new Map();p.g.forEach((g,i)=>{const key=ckey(g[0],g[1]);if(!p._gateCells.has(key))p._gateCells.set(key,[]);p._gateCells.get(key).push(i)})}for(const i of p._gateCells.get(ckey(...cell))||[])if(i!==exclude&&(!side||p.g[i][2]===side))return i;return null}
|
|||
|
|
function regionFor(level){return REGIONS[Math.min(REGIONS.length-1,Math.floor((Math.max(1,level)-1)/2))]}
|
|||
|
|
function hudPlacementCandidates(meta){
|
|||
|
|
const own=new Set(meta.chunks.map(([dx,dy])=>key2(meta.x+dx,meta.y+dy))),priority={N:0,S:1,W:2,E:3},candidates=[];
|
|||
|
|
for(const[dx,dy]of meta.chunks)for(const side of['N','S','W','E']){
|
|||
|
|
const[dr,dc]=SIDE_D[side],localKey=key2(dx+dc,dy+dr);if(own.has(key2(meta.x+dx+dc,meta.y+dy+dr)))continue;
|
|||
|
|
const neighbor=occupancy.get(key2(meta.x+dx+dc,meta.y+dy+dr));if(neighbor&&neighbor!==meta.id)continue;
|
|||
|
|
candidates.push({side,dx,dy,priority:priority[side],distance:Math.abs(dx+.5-(puzzleOf(meta).bounds.w/CHUNK)/2)+Math.abs(dy+.5-(puzzleOf(meta).bounds.h/CHUNK)/2),localKey});
|
|||
|
|
}
|
|||
|
|
return candidates.sort((a,b)=>a.priority-b.priority||a.distance-b.distance||a.dy-b.dy||a.dx-b.dx);
|
|||
|
|
}
|
|||
|
|
function positionBoardLabel(b){
|
|||
|
|
const candidates=hudPlacementCandidates(b.meta),fallback={side:'N',dx:0,dy:0},viewportRect=viewport.getBoundingClientRect(),boardRect=boardScreenRect(b),scale=Math.max(MIN_CAMERA_SCALE,cam.scale),margin=8,
|
|||
|
|
innerWidth=b.p.bounds.w*CELL,estimatedHeight=Math.max(28,b.label.offsetHeight||32)*scale;
|
|||
|
|
const boundsFor=placement=>{
|
|||
|
|
const horizontal=placement.side==='N'||placement.side==='S',labelWidth=(horizontal?innerWidth:Math.max(150,b.label.offsetWidth||190))*scale,
|
|||
|
|
edgeX=horizontal?PAD+innerWidth/2:PAD+(placement.dx+(placement.side==='E'?1:0))*UNIT,
|
|||
|
|
edgeY=horizontal?PAD+(placement.side==='S'?b.p.bounds.h*CELL:0):PAD+(placement.dy+.5)*UNIT,
|
|||
|
|
x=boardRect.left+edgeX/b.w*boardRect.width,y=boardRect.top+edgeY/b.h*boardRect.height,gap=(placement.side==='N'?22:8)*scale;
|
|||
|
|
if(placement.side==='N')return{left:x-labelWidth/2,right:x+labelWidth/2,top:y-gap-estimatedHeight,bottom:y-gap};
|
|||
|
|
if(placement.side==='S')return{left:x-labelWidth/2,right:x+labelWidth/2,top:y+gap,bottom:y+gap+estimatedHeight};
|
|||
|
|
if(placement.side==='W')return{left:x-gap-labelWidth,right:x-gap,top:y-estimatedHeight/2,bottom:y+estimatedHeight/2};
|
|||
|
|
return{left:x+gap,right:x+gap+labelWidth,top:y-estimatedHeight/2,bottom:y+estimatedHeight/2};
|
|||
|
|
};
|
|||
|
|
const overflow=rect=>Math.max(0,viewportRect.left+margin-rect.left)+Math.max(0,rect.right-(viewportRect.right-margin))+Math.max(0,viewportRect.top+margin-rect.top)+Math.max(0,rect.bottom-(viewportRect.bottom-margin));
|
|||
|
|
const placement=(candidates.length?candidates:[fallback]).map(candidate=>({candidate,overflow:overflow(boundsFor(candidate))})).sort((a,c)=>a.overflow-c.overflow||a.candidate.priority-c.candidate.priority)[0].candidate;
|
|||
|
|
b.label.dataset.side=placement.side;b.label.style.removeProperty('right');b.label.style.removeProperty('bottom');b.label.style.removeProperty('width');
|
|||
|
|
if(placement.side==='N'){b.label.style.left=PAD+'px';b.label.style.width=innerWidth+'px';b.label.style.top=(PAD-22)+'px';b.label.style.transform='translate(0,-100%)'}
|
|||
|
|
else if(placement.side==='S'){b.label.style.left=PAD+'px';b.label.style.width=innerWidth+'px';b.label.style.top=(PAD+b.p.bounds.h*CELL+8)+'px';b.label.style.transform='translate(0,0)'}
|
|||
|
|
else if(placement.side==='W'){const edgeX=PAD+placement.dx*UNIT,edgeY=PAD+(placement.dy+.5)*UNIT;b.label.style.left=(edgeX-8)+'px';b.label.style.top=edgeY+'px';b.label.style.transform='translate(-100%,-50%)'}
|
|||
|
|
else{const edgeX=PAD+(placement.dx+1)*UNIT,edgeY=PAD+(placement.dy+.5)*UNIT;b.label.style.left=(edgeX+8)+'px';b.label.style.top=edgeY+'px';b.label.style.transform='translate(0,-50%)'}
|
|||
|
|
}
|
|||
|
|
const dirtyBoards=new Set(),lineWidthDirtyPathKeys=new Set();let renderFrame=0,lineWidthFrame=0;
|
|||
|
|
function applyLineWidth(node,width){const value=width.toFixed(2);if(node.style.getPropertyValue('--line-width')!==value)node.style.setProperty('--line-width',value)}
|
|||
|
|
function linePathKey(id,index){return`${id}:${index}`}
|
|||
|
|
function splitLinePathKey(key){const split=key.lastIndexOf(':');return split<0?[key,NaN]:[key.slice(0,split),Number(key.slice(split+1))]}
|
|||
|
|
function refreshRenderedLineWidths(pathKeys=null){
|
|||
|
|
const started=perfStart(),caches=currentLineGraphCaches(),refreshAll=pathKeys==null,targetKeys=pathKeys?new Set(pathKeys):new Set();
|
|||
|
|
if(refreshAll)for(const[id,board]of rendered)for(let index=0;index<metaState(id).paths.length;index++)targetKeys.add(linePathKey(board.id,index));
|
|||
|
|
for(const key of[...targetKeys]){
|
|||
|
|
const[id,index]=splitLinePathKey(key),meta=data.metas[id];if(!meta?.puzzle||!Number.isInteger(index)||!metaState(id).paths[index])continue;
|
|||
|
|
for(const[member,memberIndex]of collectConnectedLineComponent(meta,index,caches.components).members)targetKeys.add(linePathKey(member.id,memberIndex));
|
|||
|
|
}
|
|||
|
|
const targetIds=new Set([...targetKeys].map(key=>splitLinePathKey(key)[0]));
|
|||
|
|
for(const id of targetIds){
|
|||
|
|
const board=rendered.get(id);if(!board)continue;
|
|||
|
|
const pathNodes=board.pathStrokeNodes||[];
|
|||
|
|
for(const node of pathNodes){
|
|||
|
|
const index=Number(node.dataset.pathIndex);if(!targetKeys.has(linePathKey(id,index)))continue;
|
|||
|
|
const width=renderedConnectedLineWidth(board.meta,index,caches.widths,caches.components);applyLineWidth(node,width);
|
|||
|
|
}
|
|||
|
|
const connectorNodes=board.connectorNodes||[];
|
|||
|
|
for(const node of connectorNodes){
|
|||
|
|
const ownerId=node.dataset.ownerBoard||board.id,index=Number(node.dataset.pathIndex),owner=data.metas[ownerId];
|
|||
|
|
if(!owner||!Number.isInteger(index)||!targetKeys.has(linePathKey(ownerId,index)))continue;applyLineWidth(node,renderedConnectedLineWidth(owner,index,caches.widths,caches.components));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
perfGauge('lineWidthBoards',targetIds.size);perfGauge('lineWidthPaths',targetKeys.size);perfEnd('refreshRenderedLineWidths',started);
|
|||
|
|
}
|
|||
|
|
function queueLineWidthRefresh(boardId=null,pathIndex=null){
|
|||
|
|
if(boardId){if(Number.isInteger(pathIndex))lineWidthDirtyPathKeys.add(linePathKey(boardId,pathIndex));else for(let index=0;index<metaState(boardId).paths.length;index++)lineWidthDirtyPathKeys.add(linePathKey(boardId,index))}
|
|||
|
|
if(lineWidthFrame)return;
|
|||
|
|
lineWidthFrame=requestAnimationFrame(()=>{lineWidthFrame=0;const keys=new Set(lineWidthDirtyPathKeys);lineWidthDirtyPathKeys.clear();refreshRenderedLineWidths(keys)});
|
|||
|
|
}
|
|||
|
|
function scoreLensVisibleForMeta(meta){return cam.scale<=SCORE_LENS_ZOOM_THRESHOLD&&!metaState(meta.id).solved&&activeScoreLensCount()>0}
|
|||
|
|
function updateScoreLensBadge(b){
|
|||
|
|
if(!b?.scoreLensBadge)return;const visible=scoreLensVisibleForMeta(b.meta);b.scoreLensBadge.hidden=!visible;
|
|||
|
|
if(visible){const score=formatProjectedScore(projectedScoreForBoard(b.meta));b.scoreLensBadge.textContent=`◆ ${score}`;b.scoreLensBadge.setAttribute('aria-label',`予想報酬 ${score}`);b.scoreLensBadge.setAttribute('aria-hidden','false')}else{b.scoreLensBadge.removeAttribute('aria-label');b.scoreLensBadge.setAttribute('aria-hidden','true')}
|
|||
|
|
}
|
|||
|
|
let lastPresentedScale=NaN,worldOverviewActive=false,zoomDetailsDirty=false;
|
|||
|
|
function inWorldOverview(){
|
|||
|
|
const drawingActive=document.body.classList.contains('is-drawing')||Boolean(rendered.get(activeBoard)?.drawing?.keyboardActive);
|
|||
|
|
if(drawingActive){worldOverviewActive=false;return false}
|
|||
|
|
worldOverviewActive=cam.scale<=OVERVIEW_ZOOM_THRESHOLD;
|
|||
|
|
return worldOverviewActive;
|
|||
|
|
}
|
|||
|
|
function updateZoomPresentation(force=false){
|
|||
|
|
if(!force&&Math.abs(cam.scale-lastPresentedScale)<1e-6)return inWorldOverview();lastPresentedScale=cam.scale;
|
|||
|
|
const overview=inWorldOverview();world.classList.toggle('world-overview',overview);world.classList.toggle('score-lens-zoom',cam.scale<=SCORE_LENS_ZOOM_THRESHOLD);viewport.classList.toggle('canvas-overview',overview);
|
|||
|
|
world.style.setProperty('--inverse-camera-scale',String(1/Math.max(MIN_CAMERA_SCALE,cam.scale)));world.style.setProperty('--camera-scale',String(cam.scale));
|
|||
|
|
if(document.body.classList.contains('is-interacting'))zoomDetailsDirty=true;else{for(const board of rendered.values())updateScoreLensBadge(board);zoomDetailsDirty=false}
|
|||
|
|
if(overview){overviewDirty=true;scheduleWorldOverview(true)}else if(overviewCanvas){overviewCanvas.hidden=true}
|
|||
|
|
return overview;
|
|||
|
|
}
|
|||
|
|
function flushBoardRenderQueue(timestamp){
|
|||
|
|
renderFrame=0;if(!dirtyBoards.size)return;markVisualFrame(timestamp);let count=0;
|
|||
|
|
for(const board of [...dirtyBoards]){dirtyBoards.delete(board);if(board.card.isConnected)renderBoardNow(board);if(++count>=BOARD_RENDERS_PER_FRAME)break}
|
|||
|
|
if(dirtyBoards.size)renderFrame=requestAnimationFrame(flushBoardRenderQueue);
|
|||
|
|
}
|
|||
|
|
function renderBoard(b){
|
|||
|
|
if(!b||!b.card.isConnected)return;dirtyBoards.add(b);perfCount('boardRenderRequests');if(!renderFrame)renderFrame=requestAnimationFrame(flushBoardRenderQueue);
|
|||
|
|
}
|
|||
|
|
function boardCellCenter(cell){return[PAD+(cell[1]+.5)*CELL,PAD+(cell[0]+.5)*CELL]}
|
|||
|
|
function pathRenderSegments(path,p){
|
|||
|
|
if(!path?.cells?.length)return[];const segments=[],first=boardCellCenter(path.cells[0]);let current=path.detachedStart?[first]:[gatePoint(gateObj(p,path.startGate)),first];
|
|||
|
|
for(let index=1;index<path.cells.length;index++){
|
|||
|
|
const previous=path.cells[index-1],cell=path.cells[index];
|
|||
|
|
if(isWarpTransition(p,previous,cell)){segments.push(current);current=[boardCellCenter(cell)]}
|
|||
|
|
else current.push(boardCellCenter(cell));
|
|||
|
|
}
|
|||
|
|
if(path.endGate!=null)current.push(gatePoint(gateObj(p,path.endGate)));segments.push(current);return segments.filter(segment=>segment.length>0);
|
|||
|
|
}
|
|||
|
|
function pathStrokePieces(segments,startColor,endColor){
|
|||
|
|
const pieces=[];let total=0;
|
|||
|
|
for(const points of segments)for(let i=1;i<points.length;i++)total+=Math.hypot(points[i][0]-points[i-1][0],points[i][1]-points[i-1][1]);
|
|||
|
|
let distance=0;
|
|||
|
|
for(const points of segments){
|
|||
|
|
if(points.length<2)continue;
|
|||
|
|
let segmentLength=0;for(let i=1;i<points.length;i++)segmentLength+=Math.hypot(points[i][0]-points[i-1][0],points[i][1]-points[i-1][1]);
|
|||
|
|
if(segmentLength<=0)continue;
|
|||
|
|
const startProgress=total?distance/total:0;distance+=segmentLength;const endProgress=total?distance/total:1;
|
|||
|
|
pieces.push({points,startColor:mixHex(startColor,endColor,startProgress),endColor:mixHex(startColor,endColor,endProgress),startProgress,endProgress});
|
|||
|
|
}
|
|||
|
|
return pieces;
|
|||
|
|
}
|
|||
|
|
function specialCellInfoMap(p){
|
|||
|
|
const map=new Map(),special=specialCellSet(p);
|
|||
|
|
for(const cell of special.crossings)map.set(ckey(...cell),{type:'cross',description:'\u4ea4\u5dee\u30bb\u30eb\uff1a\u30af\u30ea\u30a2\u6642\u306b\u6c34\u5e73\u3068\u5782\u76f4\u306e2\u672c\u3092\u4ea4\u5dee\u72b6\u614b\u306b\u3059\u308b'});
|
|||
|
|
for(const pair of special.warps)for(const cell of[pair.a,pair.b])map.set(ckey(...cell),{type:'warp',description:'\u30ef\u30fc\u30d7\u30bb\u30eb\uff1a\u5165\u308b\u3068\u5bfe\u306b\u306a\u308b\u30bb\u30eb\u3078\u79fb\u52d5'});
|
|||
|
|
for(const lock of special.locks){map.set(ckey(...lock.key),{type:'key',description:'\u9375\u30bb\u30eb\uff1a\u540c\u3058\u7dda\u3067\u89e6\u308c\u308b\u3068\u6249\u3092\u901a\u904e\u53ef\u80fd'});map.set(ckey(...lock.door),{type:'door',description:'\u6249\u30bb\u30eb\uff1a\u9375\u306b\u89e6\u308c\u305f\u7dda\u306e\u307f\u901a\u904e\u53ef\u80fd'})}
|
|||
|
|
return map;
|
|||
|
|
}
|
|||
|
|
function makeSpecialMarker(info,r,c){
|
|||
|
|
const x=PAD+c*CELL,y=PAD+r*CELL,cx=x+CELL/2,cy=y+CELL/2,group=svgEl('g',{class:`special-cell special-${info.type}`,'data-special-cell':ckey(r,c),'aria-hidden':'true'});
|
|||
|
|
group.append(svgEl('rect',{x:x+4,y:y+4,width:CELL-8,height:CELL-8,rx:3,class:'special-cell-frame'}));
|
|||
|
|
if(info.type==='cross')group.append(svgEl('line',{x1:cx-11,y1:cy,x2:cx+11,y2:cy,class:'special-symbol-line'}),svgEl('line',{x1:cx,y1:cy-11,x2:cx,y2:cy+11,class:'special-symbol-line'}),svgEl('circle',{cx,cy,r:4,class:'special-symbol-core'}));
|
|||
|
|
else if(info.type==='warp')group.append(svgEl('circle',{cx,cy,r:11,class:'warp-ring'}),svgEl('circle',{cx,cy,r:5,class:'warp-core'}));
|
|||
|
|
else if(info.type==='key')group.append(svgEl('circle',{cx:cx-5,cy:cy-3,r:5.2,class:'key-ring'}),svgEl('line',{x1:cx-1,y1:cy+1,x2:cx+9,y2:cy+11,class:'key-shaft'}),svgEl('line',{x1:cx+5,y1:cy+7,x2:cx+9,y2:cy+3,class:'key-tooth'}),svgEl('line',{x1:cx+8,y1:cy+10,x2:cx+12,y2:cy+6,class:'key-tooth'}))
|
|||
|
|
else{group.append(svgEl('rect',{x:cx-9,y:cy-12,width:18,height:24,rx:2,class:'door-panel'}),svgEl('circle',{cx:cx+4,cy,r:1.8,class:'door-knob'}))}
|
|||
|
|
return group;
|
|||
|
|
}
|
|||
|
|
function buildTutorialBoards(){
|
|||
|
|
const tutorialCell=34,tutorialPad=16;
|
|||
|
|
function addGrid(svg,{x=tutorialPad,y=tutorialPad,cols=4,rows=3,bg=levelBackground(1)[0]}={}){
|
|||
|
|
for(let row=0;row<rows;row++)for(let col=0;col<cols;col++)
|
|||
|
|
svg.append(svgEl('rect',{x:x+col*tutorialCell,y:y+row*tutorialCell,width:tutorialCell,height:tutorialCell,class:'cell-bg tutorial-cell',fill:bg}));
|
|||
|
|
svg.append(svgEl('rect',{x,y,width:cols*tutorialCell,height:rows*tutorialCell,fill:'none',class:'outer-edge'}));
|
|||
|
|
return{
|
|||
|
|
center:(row,col)=>[x+(col+.5)*tutorialCell,y+(row+.5)*tutorialCell],
|
|||
|
|
gate:(side,row)=>side==='W'?[x,y+(row+.5)*tutorialCell]:[x+cols*tutorialCell,y+(row+.5)*tutorialCell]
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
function addGate(svg,point,side){
|
|||
|
|
svg.append(
|
|||
|
|
svgEl('circle',{cx:point[0],cy:point[1],r:4.5,class:'gate-dot',fill:'#72e38f'}),
|
|||
|
|
svgEl('path',{d:gateMarkerPathAt(point,side,false,.82),class:'gate-marker tutorial-gate-marker'})
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
function addPath(svg,points,color,className){
|
|||
|
|
const line=svgEl('polyline',{points:points.map(point=>point.join(',')).join(' '),class:`path tutorial-path ${className}`,stroke:color,pathLength:1});
|
|||
|
|
svg.append(line);return line;
|
|||
|
|
}
|
|||
|
|
for(const host of document.querySelectorAll('[data-tutorial]')){
|
|||
|
|
const type=host.dataset.tutorial,fill=type==='fill',
|
|||
|
|
width=tutorialPad*2+tutorialCell*4,
|
|||
|
|
height=tutorialPad*2+tutorialCell*3,
|
|||
|
|
svg=svgEl('svg',{viewBox:`0 0 ${width} ${height}`,class:'tutorial-board','aria-hidden':'true'});
|
|||
|
|
svg.style.setProperty('--line-width','8px');
|
|||
|
|
if(fill){
|
|||
|
|
const grid=addGrid(svg,{bg:levelBackground(4)[0]});
|
|||
|
|
for(let row=0;row<3;row++){
|
|||
|
|
const fromLeft=row!==1,start=grid.gate(fromLeft?'W':'E',row),end=grid.gate(fromLeft?'E':'W',row),
|
|||
|
|
cells=fromLeft?[0,1,2,3]:[3,2,1,0],points=[start,...cells.map(col=>grid.center(row,col)),end];
|
|||
|
|
addGate(svg,start,fromLeft?'W':'E');addGate(svg,end,fromLeft?'E':'W');
|
|||
|
|
addPath(svg,points,LINE_COLORS[[2,0,3][row]],`tutorial-fill-path tutorial-fill-${row+1}`);
|
|||
|
|
}
|
|||
|
|
}else{
|
|||
|
|
const grid=addGrid(svg),startRow=type==='draw'?0:1,endRow=type==='draw'?2:type==='bend'?0:1,
|
|||
|
|
start=grid.gate('W',startRow),end=grid.gate('E',endRow);
|
|||
|
|
addGate(svg,start,'W');addGate(svg,end,'E');
|
|||
|
|
if(type==='draw'){
|
|||
|
|
addPath(svg,[start,grid.center(0,0),grid.center(0,1),grid.center(1,1),grid.center(1,2),grid.center(2,2),grid.center(2,3),end],LINE_COLORS[2],'tutorial-draw-path');
|
|||
|
|
}else if(type==='bend'){
|
|||
|
|
const numberCell=grid.center(1,1),number=svgEl('text',{x:numberCell[0],y:numberCell[1],class:'num tutorial-number-pulse'});
|
|||
|
|
addPath(svg,[start,grid.center(1,0),numberCell,grid.center(0,1),grid.center(0,2),grid.center(0,3),end],LINE_COLORS[3],'tutorial-bend-path');
|
|||
|
|
number.textContent='2';svg.append(number);
|
|||
|
|
}else{
|
|||
|
|
const points=[start,grid.center(1,0),grid.center(1,1),grid.center(1,2),grid.center(1,3),end];
|
|||
|
|
addPath(svg,points,LINE_COLORS[2],'tutorial-shop-path');
|
|||
|
|
const markerPoint=grid.center(1,2),marker=svgEl('g',{class:'tutorial-shop-marker'});
|
|||
|
|
marker.append(svgEl('rect',{x:markerPoint[0]-19,y:markerPoint[1]-17,width:38,height:34,class:'tutorial-shop-building'}),svgEl('text',{x:markerPoint[0],y:markerPoint[1]-1,class:'tutorial-shop-gem'}),svgEl('text',{x:markerPoint[0],y:markerPoint[1]+12,class:'tutorial-shop-label'}));
|
|||
|
|
marker.children[1].textContent='\u25a6';marker.children[2].textContent='\u5e97';svg.append(marker);
|
|||
|
|
const score=svgEl('text',{x:markerPoint[0]+31,y:markerPoint[1]-19,class:'tutorial-shop-score'});score.textContent='+ ◆';svg.append(score);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
host.replaceChildren(svg);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
buildTutorialBoards();
|
|||
|
|
function boardCellsPath(cells){return(cells||[]).map(([r,c])=>{const x=PAD+c*CELL,y=PAD+r*CELL;return`M${x} ${y}h${CELL}v${CELL}h-${CELL}Z`}).join('')}
|
|||
|
|
function outerEdgesPath(p){const set=cellSet(p),parts=[];for(const[r,c]of p.valid){const x=PAD+c*CELL,y=PAD+r*CELL;if(!set.has(ckey(r-1,c)))parts.push(`M${x} ${y}h${CELL}`);if(!set.has(ckey(r+1,c)))parts.push(`M${x} ${y+CELL}h${CELL}`);if(!set.has(ckey(r,c-1)))parts.push(`M${x} ${y}v${CELL}`);if(!set.has(ckey(r,c+1)))parts.push(`M${x+CELL} ${y}v${CELL}`)}return parts.join('')}
|
|||
|
|
function drawOuterEdges(layer,p){layer.append(svgEl('path',{d:outerEdgesPath(p),class:'outer-edge'}))}
|
|||
|
|
function overviewChunkPath(meta){return meta.chunks.map(([dx,dy])=>{const x=PAD+dx*UNIT,y=PAD+dy*UNIT;return`M${x} ${y}h${UNIT}v${UNIT}h-${UNIT}Z`}).join('')}
|
|||
|
|
function staticBoardSignature(meta){const state=metaState(meta.id);return`${meta.rev||0}:${state.rev||0}:${state.solved?1:0}:${state.paths.length}`}
|
|||
|
|
function destroyStaticBoard(board){
|
|||
|
|
if(!board)return;board.card.remove();staticRendered.delete(board.id);perfCount('staticBoardsDestroyed');
|
|||
|
|
}
|
|||
|
|
function boardTimeout(board,callback,delay){
|
|||
|
|
const timer=setTimeout(()=>{board?.timers?.delete(timer);if(board?.card?.isConnected)callback()},delay);board?.timers?.add(timer);return timer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function makeStaticBoard(meta){
|
|||
|
|
const p=puzzleOf(meta),w=p.bounds.w*CELL+PAD*2,h=p.bounds.h*CELL+PAD*2,card=document.createElement('div');
|
|||
|
|
card.className=`board-card board-static${metaState(meta.id).solved?' solved':''}`;card.style.left=((meta.x-renderOriginX)*UNIT-PAD)+'px';card.style.top=((meta.y-renderOriginY)*UNIT-PAD)+'px';card.style.width=w+'px';card.style.height=h+'px';card.dataset.id=meta.id;card.tabIndex=-1;
|
|||
|
|
const svg=svgEl('svg',{viewBox:`0 0 ${w} ${h}`,width:w,height:h,class:'board-static-svg','aria-hidden':'true'}),[background]=levelBackground(meta.level),
|
|||
|
|
shape=svgEl('path',{d:overviewChunkPath(meta),class:'static-summary-fill',fill:background});
|
|||
|
|
svg.append(shape);const state=metaState(meta.id);
|
|||
|
|
state.paths.forEach((path,index)=>{if(!path?.cells?.length)return;const color=LINE_COLORS[(path.startColorIndex??path.colorIndex??index)%LINE_COLORS.length],width=Math.min(15,Math.max(3,renderedConnectedLineWidth(meta,index)));for(const points of pathRenderSegments(path,p)){const node=svgEl('polyline',{points:points.map(point=>point.join(',')).join(' '),class:'static-summary-path',stroke:color});node.style.setProperty('--static-line-width',width.toFixed(2));svg.append(node)}});
|
|||
|
|
card.append(svg);
|
|||
|
|
const storeCell=storeCellForMeta(meta);if(storeCell){const shop=document.createElement('button'),[x,y]=boardCellCenter(storeCell);shop.type='button';shop.className='static-shop-icon';shop.innerHTML='<span class="shop-shell" aria-hidden="true"><i></i><b></b></span>';shop.style.left=x+'px';shop.style.top=y+'px';shop.setAttribute('aria-label','\u30b7\u30e7\u30c3\u30d7\u3092\u958b\u304f');shop.addEventListener('pointerdown',event=>{if(event.button!==0)return;event.preventDefault();event.stopPropagation();playSound('click');openStoreMeta(meta)});card.append(shop)}
|
|||
|
|
world.append(card);const board={id:meta.id,meta,card,svg,signature:staticBoardSignature(meta)};staticRendered.set(meta.id,board);refreshClaimPresentation(meta.id);perfCount('staticBoardsCreated');return board;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function makeBoard(meta){
|
|||
|
|
const makeStarted=perfStart();
|
|||
|
|
const p=puzzleOf(meta),w=p.bounds.w*CELL+PAD*2,h=p.bounds.h*CELL+PAD*2;
|
|||
|
|
const card=document.createElement('div');card.className='board-card';card.style.left=((meta.x-renderOriginX)*UNIT-PAD)+'px';card.style.top=((meta.y-renderOriginY)*UNIT-PAD)+'px';card.style.width=w+'px';card.style.height=h+'px';card.dataset.id=meta.id;
|
|||
|
|
const region=regionFor(meta.targetLevel??meta.level);card.dataset.region=region.name.toLowerCase();card.dataset.difficulty=String(meta.level);card.style.setProperty('--region',region.accent);
|
|||
|
|
const label=document.createElement('div');label.className='board-label';
|
|||
|
|
const solverBadge=document.createElement('div');solverBadge.className='solver-badge';solverBadge.setAttribute('aria-hidden','true');
|
|||
|
|
const claimBadge=document.createElement('div');claimBadge.className='board-claim-badge';claimBadge.hidden=true;claimBadge.setAttribute('aria-live','polite');
|
|||
|
|
const solverCaption=document.createElement('span');solverCaption.textContent='\u30af\u30ea\u30a2';
|
|||
|
|
const solverName=document.createElement('strong');solverBadge.append(solverCaption,solverName);
|
|||
|
|
const boardActions=document.createElement('div');boardActions.className='board-actions';
|
|||
|
|
const boardReset=document.createElement('button');boardReset.type='button';boardReset.className='board-action board-reset';boardReset.textContent='\u21ba \u30ea\u30bb\u30c3\u30c8';boardReset.setAttribute('aria-label','\u3053\u306e\u76e4\u9762\u306e\u7dda\u3092\u3059\u3079\u3066\u6d88\u3059');boardActions.append(boardReset);label.append(boardActions);
|
|||
|
|
const storeButton=document.createElement('button');storeButton.type='button';storeButton.className='line-store';storeButton.hidden=true;storeButton.innerHTML='<span class="shop-shell" aria-hidden="true"><i></i><b></b></span><small>SHOP</small>';
|
|||
|
|
const scoreLensBadge=document.createElement('div');scoreLensBadge.className='score-lens-badge';scoreLensBadge.hidden=true;scoreLensBadge.setAttribute('aria-hidden','true');
|
|||
|
|
const svg=svgEl('svg',{viewBox:`0 0 ${w} ${h}`,width:w,height:h,class:'board-svg',tabindex:0,role:'group','aria-label':`\u30ec\u30d9\u30eb${meta.level}`});
|
|||
|
|
const overviewLayer=svgEl('g',{class:'overview-layer'}),staticLayer=svgEl('g',{class:'static-layer'}),specialLayer=svgEl('g',{class:'special-cell-layer'}),connectorLayer=svgEl('g',{class:'connector-layer'}),pathLayer=svgEl('g',{class:'path-layer'}),dragLayer=svgEl('g',{class:'drag-layer'}),claimPreviewLayer=svgEl('g',{class:'claim-preview-layer'}),numberLayer=svgEl('g',{class:'number-layer'}),gateLayer=svgEl('g',{class:'gate-layer'});
|
|||
|
|
overviewLayer.append(svgEl('path',{d:overviewChunkPath(meta),class:'overview-fill'}));
|
|||
|
|
const[bgStart,bgEnd]=levelBackground(meta.level),bgId=`board-level-bg-${meta.id}`,bgDefs=svgEl('defs'),bgGradient=svgEl('linearGradient',{id:bgId,x1:'0%',y1:'0%',x2:'100%',y2:'100%'});
|
|||
|
|
bgGradient.append(svgEl('stop',{offset:'0%','stop-color':bgStart}),svgEl('stop',{offset:'100%','stop-color':bgEnd}));bgDefs.append(bgGradient);staticLayer.append(bgDefs);
|
|||
|
|
const validCellPath=boardCellsPath(p.valid),inputClipId=`board-input-clip-${meta.id}`,inputClip=svgEl('clipPath',{id:inputClipId,clipPathUnits:'userSpaceOnUse'}),inputClipShape=svgEl('path',{d:validCellPath});inputClip.append(inputClipShape);bgDefs.append(inputClip);
|
|||
|
|
const cellShape=svgEl('path',{d:validCellPath,class:'cell-shape',fill:`url(#${bgId})`}),unfilledWarningShape=svgEl('path',{d:'',class:'unfilled-warning-cells'}),boardInputSurface=svgEl('path',{d:validCellPath,class:'board-input-surface','aria-hidden':'true'});staticLayer.append(cellShape,unfilledWarningShape,boardInputSurface)
|
|||
|
|
for(const[r,c]of p.obstacles||[]){const x=PAD+c*CELL,y=PAD+r*CELL,group=svgEl('g',{class:'obstacle-mark'});group.append(svgEl('rect',{x,y,width:CELL,height:CELL,class:'obstacle-cell'}),svgEl('line',{x1:x+10,y1:y+10,x2:x+CELL-10,y2:y+CELL-10,class:'obstacle-cross'}),svgEl('line',{x1:x+CELL-10,y1:y+10,x2:x+10,y2:y+CELL-10,class:'obstacle-cross'}));staticLayer.append(group)}
|
|||
|
|
drawOuterEdges(staticLayer,p);
|
|||
|
|
const specialInfo=specialCellInfoMap(p),specialSets=specialCellSet(p),specialNodes=new Map();
|
|||
|
|
for(const[key,info]of specialInfo){const[r,c]=key.split(',').map(Number),marker=makeSpecialMarker(info,r,c);specialNodes.set(key,marker);specialLayer.append(marker)}
|
|||
|
|
const numberNodes=new Map(),numberWarningNodes=new Map();for(const[r,c,v]of p.n){
|
|||
|
|
const[x,y]=boardCellCenter([r,c]),key=ckey(r,c),node=svgEl('text',{x,y,class:'num'}),
|
|||
|
|
warning=svgEl('g',{class:'number-turn-warning','aria-hidden':'true'}),
|
|||
|
|
frame=svgEl('rect',{x:x-CELL*.39,y:y-CELL*.39,width:CELL*.78,height:CELL*.78,class:'number-warning-frame'}),
|
|||
|
|
caption=svgEl('text',{x,y:y+14,class:'number-warning-caption'});
|
|||
|
|
node.textContent=v;caption.textContent='\u66f2\u304c\u308b';warning.append(frame,caption);numberNodes.set(key,node);numberWarningNodes.set(key,warning);numberLayer.append(node,warning);
|
|||
|
|
}
|
|||
|
|
const gateDots=[],gateKnobs=[],gateMarkers=[],gateHits=[],gateIndexesByCell=new Map();
|
|||
|
|
p.g.forEach((_,i)=>{
|
|||
|
|
const g=gateObj(p,i),gp=gatePoint(g),sealed=!gateConnectionAllowed(meta,i),
|
|||
|
|
dot=svgEl('circle',{cx:gp[0],cy:gp[1],r:4.5,class:`gate-dot${sealed?' sealed':''}`,fill:'#7d878e'}),
|
|||
|
|
knob=svgEl('circle',{cx:gp[0],cy:gp[1],r:7,class:'gate-knob','data-gate-knob':i}),
|
|||
|
|
marker=svgEl('path',{d:gateMarkerPathAt(gp,g.side,sealed),class:`gate-marker${sealed?' sealed':''}`}),
|
|||
|
|
hit=svgEl('rect',{...gateHitBox(gp,g.side),rx:3,class:'gate-hit','clip-path':`url(#${inputClipId})`,'data-gate':i,tabindex:0,role:'button','aria-label':`${sealed?'\u7d42\u7aef':'\u30b2\u30fc\u30c8 '+(i+1)}`});
|
|||
|
|
gateDots.push(dot);gateKnobs.push(knob);gateMarkers.push(marker);gateHits.push(hit);const gateCellKey=ckey(...g.cell),cellGates=gateIndexesByCell.get(gateCellKey)||[];cellGates.push(i);gateIndexesByCell.set(gateCellKey,cellGates);
|
|||
|
|
gateLayer.append(marker,dot,knob,hit);
|
|||
|
|
});
|
|||
|
|
svg.append(overviewLayer,staticLayer,specialLayer,connectorLayer,gateLayer,pathLayer,dragLayer,claimPreviewLayer,numberLayer);card.append(label,svg,solverBadge,claimBadge,storeButton,scoreLensBadge);world.append(card);
|
|||
|
|
const b={id:meta.id,meta,p,card,svg,label,solverBadge,solverName,claimBadge,storeButton,drawing:null,pendingClaimPointer:null,pendingClaimFrame:0,armedGate:null,w,h,connectorLayer,pathLayer,dragLayer,claimPreviewLayer,numberLayer,specialLayer,dragPathIndex:null,pathStrokeNodes:[],pathVisualNodes:new Map(),connectorNodes:[],numberNodes,numberWarningNodes,numberWarningKeys:new Set(),multipleWarningKeys:new Set(),gateDots,gateKnobs,gateMarkers,gateHits,gateIndexesByCell,endpointIndexesByCell:new Map(),gateLayer,boardActions,boardReset,scoreLensBadge,cellShape,boardInputSurface,inputClipId,unfilledWarningShape,cellFlashAnimations:new Map(),specialNodes,specialInfo,specialSets,crossingKeySet:new Set(specialSets.crossings.map(cell=>ckey(...cell))),dragSpecialRevision:0,dragSpecialAppliedRevision:-1,unfilledWarningActive:false,solvedPathsRendered:false,specialCrossTriggered:false,timers:new Set()};
|
|||
|
|
positionBoardLabel(b);rendered.set(meta.id,b);bindBoard(b);renderBoardNow(b);
|
|||
|
|
if(meta.justRevealed){card.classList.add('revealing');boardTimeout(b,()=>card.classList.remove('revealing'),900);delete meta.justRevealed}
|
|||
|
|
perfCount('boardsCreated');perfEnd('makeBoard',makeStarted);return b;
|
|||
|
|
}
|
|||
|
|
function destroyBoard(board){
|
|||
|
|
if(!board)return;finishCompletionVisual(board.id,true);const pointerId=board.drawing?.pointerId;board.drawing=null;cancelBoardDragFrame(board);dirtyBoards.delete(board);for(const key of[...lineWidthDirtyPathKeys])if(key.startsWith(`${board.id}:`))lineWidthDirtyPathKeys.delete(key);
|
|||
|
|
if(pointerId!=null)safeRelease(board.svg,pointerId);
|
|||
|
|
for(const timer of board.timers||[])clearTimeout(timer);board.timers?.clear?.();
|
|||
|
|
for(const animation of board.cellFlashAnimations?.values?.()||[])try{animation.cancel()}catch(_){}
|
|||
|
|
board.cellFlashAnimations?.clear?.();board.pathStrokeNodes=[];board.connectorNodes=[];board.pathVisualNodes?.clear?.();board.numberNodes?.clear?.();board.numberWarningNodes?.clear?.();board.specialNodes?.clear?.();board.dragLayer?.replaceChildren?.();board.pathLayer?.replaceChildren?.();board.card.remove();rendered.delete(board.id);perfCount('boardsDestroyed');
|
|||
|
|
}
|
|||
|
|
function pointerPointForDrawing(drawing,point){const offset=drawing?.pointerOffset||[0,0];return[point[0]+offset[0],point[1]+offset[1]]}
|
|||
|
|
const POINTER_DIRECTION_DELTAS=Object.freeze({N:Object.freeze([-1,0]),S:Object.freeze([1,0]),W:Object.freeze([0,-1]),E:Object.freeze([0,1])});
|
|||
|
|
function setPoint(target,x,y){if(Array.isArray(target)){target[0]=x;target[1]=y;return target}return[x,y]}
|
|||
|
|
function setSvgAttr(node,name,value){const text=String(value);if(node.getAttribute(name)!==text)node.setAttribute(name,text)}
|
|||
|
|
function drawingForPath(b,pathIndex,pointerId=null,lastPoint=null){
|
|||
|
|
const path=metaState(b.id).paths[pathIndex],tip=path?.cells?.[path.cells.length-1]||[0,0],center=boardCellCenter(tip);
|
|||
|
|
return{
|
|||
|
|
phase:'dragging',pointerId,keyboardActive:pointerId==null,pathIndex,lastPoint,
|
|||
|
|
rawPointerPosition:lastPoint?[lastPoint[0],lastPoint[1]]:null,currentConfirmedCell:[tip[0],tip[1]],
|
|||
|
|
candidateDirection:null,currentCandidateCell:null,
|
|||
|
|
renderedHandlePosition:[center[0],center[1]],originGate:null,originGateCell:null,gateSnapArmed:false,leftOriginGateCell:false,
|
|||
|
|
warpDirection:null,segmentOccupancy:occupiedMap(metaState(b.id)),pathCellIndex:new Map((path?.cells||[]).map((cell,index)=>[ckey(...cell),index])),logicalRevision:0,lastModelProbeKey:null,lastRenderedTip:null
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
function syncDrawingConfirmedState(b,drawing=b?.drawing){
|
|||
|
|
const path=drawing&&metaState(b.id).paths[drawing.pathIndex],tip=path?.cells?.[path.cells.length-1];if(!tip)return false;
|
|||
|
|
drawing.currentConfirmedCell=setPoint(drawing.currentConfirmedCell,tip[0],tip[1]);
|
|||
|
|
const center=boardCellCenter(tip);drawing.renderedHandlePosition=setPoint(drawing.renderedHandlePosition,center[0],center[1]);return true;
|
|||
|
|
}
|
|||
|
|
function clearDrawingPointerState(drawing){
|
|||
|
|
if(!drawing)return;drawing.rawPointerPosition=null;drawing.candidateDirection=null;drawing.currentCandidateCell=null;
|
|||
|
|
}
|
|||
|
|
function directionFromDelta(dx,dy,preferredAxis=null){
|
|||
|
|
const adx=Math.abs(dx),ady=Math.abs(dy),largest=Math.max(adx,ady);
|
|||
|
|
if(largest<POINTER_SNAP_THRESHOLD)return null;
|
|||
|
|
const nearTie=Math.abs(adx-ady)<CELL*.12,horizontal=nearTie?preferredAxis!=='V':adx>ady;
|
|||
|
|
return horizontal?(dx<0?'W':'E'):(dy<0?'N':'S');
|
|||
|
|
}
|
|||
|
|
function updateDrawingPointerState(b,drawing,point){
|
|||
|
|
if(!drawing||!point||!syncDrawingConfirmedState(b,drawing))return null;
|
|||
|
|
drawing.rawPointerPosition=setPoint(drawing.rawPointerPosition,point[0],point[1]);
|
|||
|
|
const center=boardCellCenter(drawing.currentConfirmedCell),dx=point[0]-center[0],dy=point[1]-center[1],direction=directionFromDelta(dx,dy,pathTailAxis(activePath(b),b.p));
|
|||
|
|
drawing.candidateDirection=direction||null;
|
|||
|
|
if(direction){
|
|||
|
|
const delta=POINTER_DIRECTION_DELTAS[direction],cell=drawing.currentConfirmedCell;
|
|||
|
|
drawing.currentCandidateCell=setPoint(drawing.currentCandidateCell,cell[0]+delta[0],cell[1]+delta[1]);
|
|||
|
|
}else drawing.currentCandidateCell=null;
|
|||
|
|
updateDrawingHandlePosition(b,drawing,point);
|
|||
|
|
return direction;
|
|||
|
|
}
|
|||
|
|
function updateDrawingHandlePosition(b,drawing,point){
|
|||
|
|
if(!drawing||!point||!syncDrawingConfirmedState(b,drawing))return false;
|
|||
|
|
// Keep the model snapped to the confirmed cell; only the visual handle follows the pointer.
|
|||
|
|
const center=boardCellCenter(drawing.currentConfirmedCell),dx=point[0]-center[0],dy=point[1]-center[1],
|
|||
|
|
distance=Math.hypot(dx,dy),limit=CELL*.44,scale=distance>limit?limit/distance:1;
|
|||
|
|
drawing.renderedHandlePosition=setPoint(drawing.renderedHandlePosition,center[0]+dx*scale,center[1]+dy*scale);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
function liveEndpointPoint(b,pathIndex,actualTip){
|
|||
|
|
const drawing=b.drawing;
|
|||
|
|
if(!drawing||drawing.pointerId==null||drawing.pathIndex!==pathIndex)return actualTip;
|
|||
|
|
return drawing.renderedHandlePosition||actualTip;
|
|||
|
|
}
|
|||
|
|
function clearDragRender(b){
|
|||
|
|
if(!b)return;
|
|||
|
|
if(Number.isInteger(b.dragPathIndex))for(const node of b.pathVisualNodes?.get(b.dragPathIndex)||[])node.style.removeProperty('display');
|
|||
|
|
if(Number.isInteger(b.dragGateIndex))b.gateKnobs?.[b.dragGateIndex]?.classList.remove('drag-target');
|
|||
|
|
if(b.dragLayer)b.dragLayer.style.display='none';if(b.dragCache){b.dragCache.logicalRevision=-1;b.dragCache.geometryRevision=-1}b.dragPathIndex=null;b.dragGateIndex=null;b.dragSpecialAppliedRevision=-1;
|
|||
|
|
}
|
|||
|
|
function buildDragCache(b,index,blended,count){
|
|||
|
|
const layer=b.dragLayer,defs=svgEl('defs'),strokes=[],gradients=[];layer.replaceChildren(defs);
|
|||
|
|
for(let pieceIndex=0;pieceIndex<count;pieceIndex++){
|
|||
|
|
let stroke=LINE_COLORS[0];
|
|||
|
|
if(blended){
|
|||
|
|
const id=`drag-gradient-${b.id}-${index}-${pieceIndex}`,gradient=svgEl('linearGradient',{id,gradientUnits:'userSpaceOnUse'}),start=svgEl('stop',{offset:'0%'}),end=svgEl('stop',{offset:'100%'});
|
|||
|
|
gradient.append(start,end);defs.append(gradient);gradients.push({gradient,start,end});stroke=`url(#${id})`;
|
|||
|
|
}
|
|||
|
|
const node=svgEl('polyline',{class:'path drag-path',stroke});strokes.push(node);layer.append(node);
|
|||
|
|
}
|
|||
|
|
const liveTail=svgEl('line',{class:'path drag-path drag-live-tail'}),startHalo=svgEl('circle',{r:7,class:'endpoint-halo'}),startKnob=svgEl('circle',{r:4.8,class:'endpoint-knob'}),
|
|||
|
|
matchOrbit=svgEl('g',{class:'number-match-orbit'}),matchOrbitRing=svgEl('circle',{r:12.5,class:'number-match-orbit-ring'}),
|
|||
|
|
matchOrbitHoles=svgEl('circle',{r:12.5,class:'number-match-orbit-holes'}),halo=svgEl('circle',{r:7,class:'endpoint-halo'}),
|
|||
|
|
knob=svgEl('circle',{r:4.8,class:'endpoint-knob'}),cursorClipId=`drag-cursor-clip-${b.id}-${index}`,
|
|||
|
|
cursorClip=svgEl('clipPath',{id:cursorClipId}),cursorClipCircle=svgEl('circle',{cx:0,cy:0,r:DRAG_FLAG_CLIP_RADIUS}),
|
|||
|
|
cursorImage=svgEl('image',{x:-DRAG_FLAG_CURSOR_SIZE/2,y:-DRAG_FLAG_CURSOR_SIZE/2,width:DRAG_FLAG_CURSOR_SIZE,height:DRAG_FLAG_CURSOR_SIZE,class:'endpoint-cursor-image','clip-path':`url(#${cursorClipId})`,preserveAspectRatio:'xMidYMid slice'}),
|
|||
|
|
cursorEmoji=svgEl('text',{x:0,y:0,class:'endpoint-cursor-emoji'}),badge=svgEl('circle',{cx:19,cy:-19,r:10.5,class:'turn-badge'}),turns=svgEl('text',{x:19,y:-18.5,class:'turn-count'}),tipGroup=svgEl('g',{class:'drag-tip-group'});
|
|||
|
|
cursorImage.style.display='none';cursorEmoji.style.display='none';cursorClip.append(cursorClipCircle);defs.append(cursorClip);
|
|||
|
|
matchOrbit.append(matchOrbitRing,matchOrbitHoles);startHalo.style.display='none';startKnob.style.display='none';tipGroup.append(matchOrbit,halo,knob,cursorImage,cursorEmoji,badge,turns);
|
|||
|
|
layer.append(liveTail,startHalo,startKnob,tipGroup);return b.dragCache={index,blended,count,strokes,gradients,logicalRevision:-1,geometryRevision:-1,startHalo,startKnob,liveTail,tipGroup,matchOrbit,matchOrbitRing,matchOrbitHoles,halo,knob,cursorImage,cursorEmoji,badge,turns};
|
|||
|
|
}
|
|||
|
|
function activeCustomCursorItem(){return CURSOR_ITEM_BY_STYLE.get(data.cursorStyle)||null}
|
|||
|
|
function updateDragCursorDesign(cache){
|
|||
|
|
const item=activeCustomCursorItem(),showFlag=Boolean(item?.flagAsset),showEmoji=Boolean(item?.cursorEmoji&&!showFlag);
|
|||
|
|
if(cache.cursorDesignStyle!==(item?.cursorStyle||null)){
|
|||
|
|
cache.cursorDesignStyle=item?.cursorStyle||null;cache.cursorImage.style.display=showFlag?'':'none';cache.cursorEmoji.style.display=showEmoji?'':'none';
|
|||
|
|
if(showFlag)cache.cursorImage.setAttribute('href',item.flagAsset);
|
|||
|
|
if(showEmoji)cache.cursorEmoji.textContent=item.cursorEmoji;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
function refreshDragSpecialState(b){
|
|||
|
|
if(b.dragSpecialAppliedRevision===b.dragSpecialRevision)return;b.dragSpecialAppliedRevision=b.dragSpecialRevision;
|
|||
|
|
const st=metaState(b.id),occupiedKeys=b.drawing?.segmentOccupancy||occupiedMap(st);
|
|||
|
|
const special=b.specialSets;
|
|||
|
|
for(const cell of special.crossings)b.specialNodes.get(ckey(...cell))?.classList.toggle('activated',Boolean(crossingStateAtCell(st,b.p,cell)));
|
|||
|
|
for(const pair of special.warps)for(const cell of[pair.a,pair.b])b.specialNodes.get(ckey(...cell))?.classList.toggle('activated',occupiedKeys.has(ckey(...cell)));
|
|||
|
|
for(const lock of special.locks){const keyTouched=st.paths.some(path=>path.cells.some(cell=>sameCell(cell,lock.key))),doorUnlocked=st.paths.some(path=>pathHasLockKey(path,lock));b.specialNodes.get(ckey(...lock.key))?.classList.toggle('activated',keyTouched);b.specialNodes.get(ckey(...lock.door))?.classList.toggle('unlocked',doorUnlocked)}
|
|||
|
|
}
|
|||
|
|
function matchingNumberKeys(path,p){
|
|||
|
|
if(!path?.cells?.length)return new Set();
|
|||
|
|
const turns=partialTurnCount(path,p),occupied=new Set(path.cells.map(cell=>ckey(...cell)));
|
|||
|
|
return new Set(p.n.filter(([r,c,value])=>value===turns&&occupied.has(ckey(r,c))).map(([r,c])=>ckey(r,c)));
|
|||
|
|
}
|
|||
|
|
function showNumberMatchEffect(b,key,cache){
|
|||
|
|
const cell=key.split(',').map(Number),[cx,cy]=boardCellCenter(cell),pulse=svgEl('circle',{cx,cy,r:9,class:'number-match-pulse'});
|
|||
|
|
b.numberLayer.append(pulse);boardTimeout(b,()=>pulse.remove(),420);
|
|||
|
|
}
|
|||
|
|
function updateNumberMatchFeedback(b,path,cache){
|
|||
|
|
const drawing=b?.drawing;if(!drawing)return;
|
|||
|
|
const current=matchingNumberKeys(path,b.p),previous=drawing.matchingNumberKeys;
|
|||
|
|
if(previous)for(const key of current)if(!previous.has(key))showNumberMatchEffect(b,key,cache);
|
|||
|
|
cache.knob.classList.toggle('number-match',current.size>0);cache.halo.classList.toggle('number-match',current.size>0);cache.matchOrbit.classList.toggle('show',current.size>0);
|
|||
|
|
drawing.matchingNumberKeys=current;
|
|||
|
|
}
|
|||
|
|
function drawingLineWidth(b,pathIndex){const drawing=b?.drawing;if(drawing?.pathIndex===pathIndex&&Number.isFinite(drawing.lineWidth))return drawing.lineWidth;const width=renderedConnectedLineWidth(b.meta,pathIndex);if(drawing?.pathIndex===pathIndex)drawing.lineWidth=width;return width}
|
|||
|
|
function renderDragFrame(b){
|
|||
|
|
const started=perfStart(),drawing=b?.drawing,path=activePath(b);if(!drawing||drawing.pointerId==null||!path?.cells?.length){clearDragRender(b);return false}
|
|||
|
|
if(!drawing.pickupStartMeasured){
|
|||
|
|
drawing.pickupStartMeasured=true;perfGauge('pickupStartFullBoardRenders',Math.max(0,(perfCounters.fullBoardRenders||0)-(drawing.fullBoardRendersAtPointerDown||0)));
|
|||
|
|
if(Number.isFinite(drawing.pointerDownAt))perfObserve('pickupStartLatency',Math.max(0,perfNow()-drawing.pointerDownAt));
|
|||
|
|
}
|
|||
|
|
dirtyBoards.delete(b);
|
|||
|
|
const index=drawing.pathIndex,baseNodes=b.pathVisualNodes?.get(index)||[];if(!baseNodes.length&&!drawing.createdPath){renderBoardNow(b);return false}
|
|||
|
|
if(b.dragPathIndex!==index){clearDragRender(b);b.dragPathIndex=index;for(const node of baseNodes)node.style.display='none'}
|
|||
|
|
b.dragLayer.style.display='';
|
|||
|
|
const logicalRevision=drawing.logicalRevision||0,startIndex=path.startColorIndex??path.colorIndex??index,endIndex=path.endColorIndex??startIndex,
|
|||
|
|
startColor=LINE_COLORS[startIndex%LINE_COLORS.length],endColor=LINE_COLORS[endIndex%LINE_COLORS.length],
|
|||
|
|
blended=false,lineWidth=drawingLineWidth(b,index);
|
|||
|
|
if(drawing.renderGeometryRevision!==logicalRevision||drawing.renderBlended!==blended){
|
|||
|
|
drawing.renderGeometryRevision=logicalRevision;drawing.renderBlended=blended;drawing.renderSegments=pathRenderSegments(path,b.p);drawing.renderTurnCount=String(partialTurnCount(path,b.p));
|
|||
|
|
drawing.renderPieces=blended?pathStrokePieces(drawing.renderSegments,startColor,endColor):drawing.renderSegments.map(points=>({points}));
|
|||
|
|
}
|
|||
|
|
const pieces=drawing.renderPieces||[],cache=!b.dragCache||b.dragCache.index!==index||b.dragCache.blended!==blended||b.dragCache.count!==pieces.length?buildDragCache(b,index,blended,pieces.length):b.dragCache;
|
|||
|
|
const geometryChanged=cache.geometryRevision!==logicalRevision;
|
|||
|
|
if(geometryChanged){
|
|||
|
|
pieces.forEach((piece,pieceIndex)=>{
|
|||
|
|
const node=cache.strokes[pieceIndex],points=piece.points,pointsText=points.map(point=>point.join(',')).join(' ');if(node.getAttribute('points')!==pointsText)node.setAttribute('points',pointsText);applyLineWidth(node,lineWidth);
|
|||
|
|
if(blended){const first=points[0],last=points[points.length-1],gradient=cache.gradients[pieceIndex];gradient.gradient.setAttribute('x1',first[0]);gradient.gradient.setAttribute('y1',first[1]);gradient.gradient.setAttribute('x2',last[0]);gradient.gradient.setAttribute('y2',last[1]);gradient.start.setAttribute('stop-color',piece.startColor);gradient.end.setAttribute('stop-color',piece.endColor)}
|
|||
|
|
else if(node.getAttribute('stroke')!==startColor)node.setAttribute('stroke',startColor);
|
|||
|
|
});cache.geometryRevision=logicalRevision;
|
|||
|
|
}
|
|||
|
|
const actualTip=boardCellCenter(path.cells[path.cells.length-1]),tip=liveEndpointPoint(b,index,actualTip),displayColorIndex=displayedEndpointColorIndex(b.meta,path,'end'),color=LINE_COLORS[displayColorIndex%LINE_COLORS.length],turnCount=drawing.renderTurnCount||'0';
|
|||
|
|
const previousTip=drawing.lastRenderedTip;if(previousTip&&cache.logicalRevision===logicalRevision&&Math.abs(previousTip[0]-tip[0])<.45&&Math.abs(previousTip[1]-tip[1])<.45){perfEnd('renderDragFrame',started);return true}drawing.lastRenderedTip=setPoint(drawing.lastRenderedTip,tip[0],tip[1]);
|
|||
|
|
const tailNow=perfNow(),tailDue=geometryChanged||!drawing.lastTailRenderAt||tailNow-drawing.lastTailRenderAt>=32;
|
|||
|
|
if(tailDue){drawing.lastTailRenderAt=tailNow;setSvgAttr(cache.liveTail,'x1',actualTip[0]);setSvgAttr(cache.liveTail,'y1',actualTip[1]);setSvgAttr(cache.liveTail,'x2',tip[0]);setSvgAttr(cache.liveTail,'y2',tip[1]);setSvgAttr(cache.liveTail,'stroke',startColor);applyLineWidth(cache.liveTail,lineWidth)}
|
|||
|
|
if(path.detachedStart){const startTip=boardCellCenter(path.cells[0]);for(const node of[cache.startHalo,cache.startKnob]){node.style.display='';node.setAttribute('cx',startTip[0]);node.setAttribute('cy',startTip[1])}cache.startKnob.setAttribute('fill',startColor)}else for(const node of[cache.startHalo,cache.startKnob])node.style.display='none';
|
|||
|
|
const tipTransform=`translate3d(${tip[0]}px,${tip[1]}px,0)`;if(cache.tipGroup.style.transform!==tipTransform)cache.tipGroup.style.transform=tipTransform;
|
|||
|
|
setSvgAttr(cache.knob,'fill',color);if(data.cursorStyle!=='default')updateDragCursorDesign(cache);if(cache.turns.textContent!==turnCount)cache.turns.textContent=turnCount;refreshDragSpecialState(b);
|
|||
|
|
if(cache.logicalRevision!==logicalRevision){updateNumberMatchFeedback(b,path,cache);refreshDragNumberColors(b);cache.logicalRevision=logicalRevision}
|
|||
|
|
const gateIndex=gateAtCell(b.p,path.cells[path.cells.length-1],path.startGate),nextGateIndex=Number.isInteger(gateIndex)?gateIndex:null;
|
|||
|
|
if(b.dragGateIndex!==nextGateIndex){if(Number.isInteger(b.dragGateIndex))b.gateKnobs[b.dragGateIndex]?.classList.remove('drag-target');b.dragGateIndex=nextGateIndex;if(Number.isInteger(nextGateIndex))b.gateKnobs[nextGateIndex]?.classList.add('drag-target')}
|
|||
|
|
perfCount('dragRenders');perfEnd('renderDragFrame',started);return true;
|
|||
|
|
}
|
|||
|
|
function refreshDragNumberColors(b){
|
|||
|
|
const st=metaState(b.id),drawing=b.drawing,path=drawing&&st.paths[drawing.pathIndex];if(!drawing||!path)return;
|
|||
|
|
const current=new Set(path.cells.map(cell=>ckey(...cell)).filter(key=>b.numberNodes.has(key))),affected=new Set([...(drawing.dragNumberKeys||[]),...current]),occupancy=drawing.segmentOccupancy||occupiedMap(st);
|
|||
|
|
for(const key of affected){
|
|||
|
|
const node=b.numberNodes.get(key);if(!node)continue;const pathIndex=occupancy.get(key),owner=Number.isInteger(pathIndex)?st.paths[pathIndex]:null,cell=key.split(',').map(Number),lineColor=owner?pathColorAtCell(owner,b.p,cell):null;
|
|||
|
|
node.classList.toggle('on-line',Boolean(lineColor));if(lineColor)node.style.setProperty('--number-line-color',lineColor);else node.style.removeProperty('--number-line-color');
|
|||
|
|
}
|
|||
|
|
drawing.dragNumberKeys=current;
|
|||
|
|
}
|
|||
|
|
function boardPlayHudVisible(b,solved=metaState(b.id).solved){return !solved&&hudBoardId===b?.id}
|
|||
|
|
function renderBoardNow(b){
|
|||
|
|
const renderStarted=perfStart();perfCount('fullBoardRenders');
|
|||
|
|
clearDragRender(b);
|
|||
|
|
const{p,meta,card,label,pathLayer,connectorLayer}=b,st=metaState(meta.id);
|
|||
|
|
rebuildEndpointIndexes(b,st);
|
|||
|
|
const solved=!!st.solved,validation=new Map(),lineLengthCache=currentLineGraphCaches().widths;
|
|||
|
|
connectorLayer.replaceChildren();b.connectorNodes=[];card.classList.toggle('solved',solved);applyClaimPresentationToBoard(b);updateScoreLensBadge(b);const hudVisible=boardPlayHudVisible(b,solved);card.classList.toggle('hud-current',hudVisible);if(hudVisible)positionBoardLabel(b);b.boardActions.hidden=solved;
|
|||
|
|
const solver=st.solvedBy&&st.solvedBy!==LEGACY_LOCAL_SOLVER?st.solvedBy:currentPlayerName();
|
|||
|
|
b.solverName.textContent=solver;b.solverBadge.setAttribute('aria-hidden',solved?'false':'true');
|
|||
|
|
const store=solved?st.store:null,storePath=store?st.paths[store.pathIndex]:null,storeCell=storePath?.cells?.[store.cellIndex];
|
|||
|
|
b.storeButton.hidden=!storeCell;
|
|||
|
|
if(storeCell){
|
|||
|
|
const[x,y]=boardCellCenter(storeCell);b.storeButton.style.left=x+'px';b.storeButton.style.top=y+'px';
|
|||
|
|
b.storeButton.setAttribute('aria-label',`${store.owner}\u306e\u30b7\u30e7\u30c3\u30d7`);
|
|||
|
|
b.storeButton.title=`${store.owner}\u306e\u30b7\u30e7\u30c3\u30d7`;
|
|||
|
|
}
|
|||
|
|
card.tabIndex=solved?0:-1;if(solved)card.setAttribute('aria-label',`\u30ec\u30d9\u30eb${meta.level}\u3002\u30af\u30ea\u30a2\u6e08\u307f\u3002`);else card.removeAttribute('aria-label');
|
|||
|
|
b.svg.setAttribute('tabindex',solved?-1:0);
|
|||
|
|
if(!solved)for(const path of st.paths)if(path.endGate!=null)validation.set(path,pathValid(path,p));
|
|||
|
|
const invalidPaths=solved?0:[...validation.values()].filter(valid=>!valid).length;
|
|||
|
|
card.classList.toggle('has-errors',invalidPaths>0);b.hadInvalidPaths=invalidPaths>0;
|
|||
|
|
const occupiedKeys=new Set();for(const route of st.paths)for(const cell of route?.cells||[])occupiedKeys.add(ckey(...cell));
|
|||
|
|
const special=specialCellSet(p);
|
|||
|
|
for(const cell of special.crossings)b.specialNodes.get(ckey(...cell))?.classList.toggle('activated',Boolean(crossingStateAtCell(st,p,cell)));
|
|||
|
|
for(const pair of special.warps)for(const cell of[pair.a,pair.b])b.specialNodes.get(ckey(...cell))?.classList.toggle('activated',occupiedKeys.has(ckey(...cell)));
|
|||
|
|
for(const lock of special.locks){
|
|||
|
|
const keyTouched=st.paths.some(path=>path.cells.some(cell=>sameCell(cell,lock.key))),doorUnlocked=st.paths.some(path=>pathHasLockKey(path,lock));
|
|||
|
|
b.specialNodes.get(ckey(...lock.key))?.classList.toggle('activated',keyTouched);b.specialNodes.get(ckey(...lock.door))?.classList.toggle('unlocked',doorUnlocked);
|
|||
|
|
}
|
|||
|
|
const allLinesConnected=st.paths.length===p.n.length&&st.paths.length>0&&st.paths.every(route=>route?.endGate!=null),emptyKeys=new Set(p.valid.map(cell=>ckey(...cell)).filter(key=>!occupiedKeys.has(key))),unfilledWarning=!solved&&allLinesConnected&&emptyKeys.size>0;
|
|||
|
|
if(b.unfilledWarningShape)b.unfilledWarningShape.setAttribute('d',unfilledWarning?boardCellsPath(p.valid.filter(cell=>emptyKeys.has(ckey(...cell)))):'');
|
|||
|
|
card.classList.toggle('has-unfilled-warning',unfilledWarning);
|
|||
|
|
const showUnfilledWarning=unfilledWarning&&!b.unfilledWarningActive&&activeBoard===meta.id;
|
|||
|
|
b.unfilledWarningActive=unfilledWarning;
|
|||
|
|
label.replaceChildren(Object.assign(document.createElement('b'),{textContent:`\u30ec\u30d9\u30eb${meta.level}`}),b.boardActions);
|
|||
|
|
if(!solved||!b.solvedPathsRendered){
|
|||
|
|
pathLayer.replaceChildren();b.pathStrokeNodes=[];b.pathVisualNodes=new Map();
|
|||
|
|
const defs=svgEl('defs');pathLayer.append(defs);
|
|||
|
|
st.paths.forEach((path,index)=>{
|
|||
|
|
if(!path.cells.length)return;
|
|||
|
|
const visualNodes=[];b.pathVisualNodes.set(index,visualNodes);
|
|||
|
|
const pointerDragging=b.drawing?.pathIndex===index&&b.drawing.pointerId!=null,segments=pathRenderSegments(path,p),actualTip=boardCellCenter(path.cells[path.cells.length-1]),visualTip=pointerDragging?liveEndpointPoint(b,index,actualTip):actualTip;
|
|||
|
|
if(pointerDragging&&path.endGate==null&&segments.length){const liveSegment=segments[segments.length-1];if(liveSegment.length)liveSegment[liveSegment.length-1]=visualTip}
|
|||
|
|
const startIndex=path.startColorIndex??path.colorIndex??index,endIndex=path.endColorIndex??startIndex,colorEndGate=path.endGate??path.openGate,
|
|||
|
|
startColor=LINE_COLORS[startIndex%LINE_COLORS.length],endColor=LINE_COLORS[endIndex%LINE_COLORS.length],invalid=path.endGate!=null&&!validation.get(path)&&!solved,
|
|||
|
|
lineWidth=renderedConnectedLineWidth(meta,index,lineLengthCache),blended=!uiSettings.lightweightRendering&&colorEndGate!=null&&endIndex!==startIndex;
|
|||
|
|
if(blended){
|
|||
|
|
for(const[pieceIndex,piece]of pathStrokePieces(segments,startColor,endColor).entries()){
|
|||
|
|
const first=piece.points[0],last=piece.points[piece.points.length-1],gradientId=`path-gradient-${b.id}-${index}-${pieceIndex}`,gradient=svgEl('linearGradient',{id:gradientId,gradientUnits:'userSpaceOnUse',x1:first[0],y1:first[1],x2:last[0],y2:last[1]});
|
|||
|
|
gradient.append(svgEl('stop',{offset:'0%','stop-color':piece.startColor}),svgEl('stop',{offset:'100%','stop-color':piece.endColor}));defs.append(gradient);
|
|||
|
|
const pathNode=svgEl('polyline',{points:piece.points.map(q=>q.join(',')).join(' '),class:`path${invalid?' invalid':''}`,stroke:`url(#${gradientId})`,'data-path-index':index,'data-path-progress-start':piece.startProgress.toFixed(6),'data-path-progress-end':piece.endProgress.toFixed(6)});
|
|||
|
|
applyLineWidth(pathNode,lineWidth);b.pathStrokeNodes.push(pathNode);visualNodes.push(pathNode);pathLayer.append(pathNode);
|
|||
|
|
}
|
|||
|
|
}else for(const points of segments){
|
|||
|
|
const pathNode=svgEl('polyline',{points:points.map(q=>q.join(',')).join(' '),class:`path${invalid?' invalid':''}`,stroke:startColor,'data-path-index':index});
|
|||
|
|
applyLineWidth(pathNode,lineWidth);b.pathStrokeNodes.push(pathNode);visualNodes.push(pathNode);pathLayer.append(pathNode);
|
|||
|
|
}
|
|||
|
|
if(path.endGate==null){
|
|||
|
|
const tip=visualTip,isActive=b.drawing?.pathIndex===index,turns=partialTurnCount(path,p),bx=tip[0]+19,by=tip[1]-19,endPickupColor=LINE_COLORS[displayedEndpointColorIndex(meta,path,'end')%LINE_COLORS.length],startPickupColor=LINE_COLORS[displayedEndpointColorIndex(meta,path,'start')%LINE_COLORS.length];
|
|||
|
|
const
|
|||
|
|
halo=svgEl('circle',{cx:tip[0],cy:tip[1],r:7,class:'endpoint-halo'}),knob=svgEl('circle',{cx:tip[0],cy:tip[1],r:4.8,class:'endpoint-knob',fill:endPickupColor}),hitNode=svgEl('circle',{cx:tip[0],cy:tip[1],r:24,class:'endpoint-hit','clip-path':`url(#${b.inputClipId})`,'data-path-index':index,'data-endpoint-side':'end',tabindex:0,role:'button','aria-label':`\u7dda\u306e\u7d9a\u304d\u3092\u5f15\u304f: ${index+1}`});
|
|||
|
|
if(path.detachedStart){const startTip=boardCellCenter(path.cells[0]),startHalo=svgEl('circle',{cx:startTip[0],cy:startTip[1],r:7,class:'endpoint-halo'}),startKnob=svgEl('circle',{cx:startTip[0],cy:startTip[1],r:4.8,class:'endpoint-knob',fill:startPickupColor}),startHit=svgEl('circle',{cx:startTip[0],cy:startTip[1],r:24,class:'endpoint-hit','clip-path':`url(#${b.inputClipId})`,'data-path-index':index,'data-endpoint-side':'start',tabindex:0,role:'button','aria-label':`\u7dda\u306e\u53cd\u5bfe\u5074\u3092\u5f15\u304f: ${index+1}`});visualNodes.push(startHalo,startKnob,startHit);pathLayer.append(startHalo,startKnob,startHit)}
|
|||
|
|
visualNodes.push(halo,knob,hitNode);pathLayer.append(halo,knob,hitNode);
|
|||
|
|
if(isActive){const badge=svgEl('circle',{cx:bx,cy:by,r:10.5,class:'turn-badge'}),t=svgEl('text',{x:bx,y:by+.5,class:'turn-count'});t.textContent=turns;visualNodes.push(badge,t);pathLayer.append(badge,t)}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
b.solvedPathsRendered=solved;
|
|||
|
|
}
|
|||
|
|
if(!solved)b.solvedPathsRendered=false;
|
|||
|
|
p.g.forEach((_,gi)=>{
|
|||
|
|
const hit=matchingNeighborGate(meta,gi);if(!hit)return;
|
|||
|
|
const neighborState=metaState(hit.meta.id),own=gateUsedPath(st,gi),other=gateUsedPath(neighborState,hit.gateIndex);
|
|||
|
|
if(!own&&!other)return;
|
|||
|
|
const localColorIndex=confirmedBoundaryColorIndex(meta,gi)??pathColorIndexAtGate(own,gi)??pathColorIndexAtGate(other,hit.gateIndex)??0,
|
|||
|
|
localColor=LINE_COLORS[localColorIndex%LINE_COLORS.length],
|
|||
|
|
g=gateObj(p,gi),gp=gatePoint(g),[dr,dc]=SIDE_D[g.side],end=[gp[0]+dc*CELL*.5,gp[1]+dr*CELL*.5];
|
|||
|
|
const connector=svgEl('line',{x1:gp[0],y1:gp[1],x2:end[0],y2:end[1],class:'path connector',stroke:localColor});
|
|||
|
|
if(own){connector.dataset.ownerBoard=meta.id;connector.dataset.pathIndex=String(st.paths.indexOf(own))}
|
|||
|
|
else{connector.dataset.ownerBoard=hit.meta.id;connector.dataset.pathIndex=String(neighborState.paths.indexOf(other))}
|
|||
|
|
const owner=data.metas[connector.dataset.ownerBoard],ownerIndex=Number(connector.dataset.pathIndex);
|
|||
|
|
if(owner&&Number.isInteger(ownerIndex)){const width=renderedConnectedLineWidth(owner,ownerIndex,lineLengthCache);applyLineWidth(connector,width)}
|
|||
|
|
b.connectorNodes.push(connector);connectorLayer.append(connector);
|
|||
|
|
});
|
|||
|
|
if(!solved){
|
|||
|
|
const bad=new Set(),turnWarnings=straightNumberWarningKeys(st,p),multipleWarnings=multipleNumberWarningKeys(st,p),
|
|||
|
|
warningKeys=new Set([...turnWarnings,...multipleWarnings]);st.paths.forEach(path=>{if(path.endGate==null)return;const nums=numbersForPath(path,p),ta=turnAnalysis(path,p);for(const num of nums)if(nums.length!==1||num.value!==ta.count||!ta.cells.some(c=>sameCell(c,num.cell)))bad.add(ckey(...num.cell))});
|
|||
|
|
const numberOccupancy=occupiedMap(st);
|
|||
|
|
for(const[key,node]of b.numberNodes){
|
|||
|
|
const multiple=multipleWarnings.has(key),warning=b.numberWarningNodes.get(key),pathIndex=numberOccupancy.get(key),path=Number.isInteger(pathIndex)?st.paths[pathIndex]:null,
|
|||
|
|
cell=key.split(',').map(Number),lineColor=path?pathColorAtCell(path,p,cell):null;
|
|||
|
|
node.classList.toggle('bad',bad.has(key));node.classList.toggle('turn-warning',warningKeys.has(key));node.classList.toggle('multiple-warning',multiple);node.classList.toggle('on-line',Boolean(lineColor));
|
|||
|
|
if(lineColor)node.style.setProperty('--number-line-color',lineColor);else node.style.removeProperty('--number-line-color');
|
|||
|
|
warning?.classList.toggle('show',warningKeys.has(key));warning?.classList.toggle('multiple',multiple);
|
|||
|
|
const caption=warning?.querySelector('.number-warning-caption');if(caption)caption.textContent=multiple?'1\u7dda1\u500b':'\u66f2\u304c\u308b';
|
|||
|
|
}
|
|||
|
|
if(activeBoard===meta.id&&[...multipleWarnings].some(key=>!b.multipleWarningKeys.has(key)))toast('\u6570\u5b57\u306f1\u672c\u306e\u7dda\u306b1\u3064\u3067\u3059\u3002');
|
|||
|
|
else if(activeBoard===meta.id&&[...turnWarnings].some(key=>!b.numberWarningKeys.has(key)))toast('\u6570\u5b57\u306e\u30de\u30b9\u3067\u3001\u8868\u793a\u56de\u6570\u3068\u66f2\u304c\u308a\u6570\u3092\u5408\u308f\u305b\u307e\u3059\u3002');
|
|||
|
|
b.numberWarningKeys=turnWarnings;b.multipleWarningKeys=multipleWarnings;
|
|||
|
|
if(showUnfilledWarning)toast('\u307e\u3060\u7a7a\u3044\u3066\u3044\u308b\u30de\u30b9\u304c\u3042\u308a\u307e\u3059\u3002');
|
|||
|
|
|
|||
|
|
}
|
|||
|
|
const gateOwnColors=new Map();
|
|||
|
|
st.paths.forEach(path=>{
|
|||
|
|
if(!path.detachedStart)gateOwnColors.set(path.startGate,LINE_COLORS[(pathColorIndexAtGate(path,path.startGate)??0)%LINE_COLORS.length]);
|
|||
|
|
if(path.endGate!=null)gateOwnColors.set(path.endGate,LINE_COLORS[(pathColorIndexAtGate(path,path.endGate)??0)%LINE_COLORS.length]);
|
|||
|
|
if(path.openGate!=null)gateOwnColors.set(path.openGate,LINE_COLORS[(pathColorIndexAtGate(path,path.openGate)??0)%LINE_COLORS.length]);
|
|||
|
|
});
|
|||
|
|
p.g.forEach((g,i)=>{
|
|||
|
|
const inherited=sharedBoundaryColorIndex(meta,i),path=gateUsedPath(st,i),
|
|||
|
|
color=inherited!=null?LINE_COLORS[inherited]:(gateOwnColors.get(i)||'#f4f5f5'),
|
|||
|
|
sealed=!gateConnectionAllowed(meta,i),[gr,gc]=globalCell(meta,[g[0],g[1]]),[dr,dc]=SIDE_D[g[2]],
|
|||
|
|
frontier=!sealed&&!matchingNeighborGate(meta,i)&&!unitOccupiedAtGlobalCell(gr+dr,gc+dc,meta.id),
|
|||
|
|
tip=path&&path.endGate==null&&path.cells.length?path.cells[path.cells.length-1]:null,
|
|||
|
|
connected=Boolean(path&&path.endGate!=null),
|
|||
|
|
armed=b.armedGate===i||path?.openGate===i||(path&&path.endGate==null&&sameCell(tip,[g[0],g[1]])),
|
|||
|
|
gate=gateObj(p,i),gp=gatePoint(gate),gateCellKey=ckey(g[0],g[1]),available=!solved&&!path&&!occupiedKeys.has(gateCellKey);
|
|||
|
|
b.gateDots[i].setAttribute('fill',color);
|
|||
|
|
b.gateDots[i].classList.toggle('sealed',sealed);
|
|||
|
|
b.gateDots[i].classList.toggle('frontier',frontier);
|
|||
|
|
b.gateKnobs[i]?.setAttribute('fill',color);
|
|||
|
|
b.gateKnobs[i]?.classList.toggle('connected',connected&&!sealed);
|
|||
|
|
b.gateKnobs[i]?.classList.toggle('show',(Boolean(armed)||connected||available||Boolean(path?.openGate===i))&&!sealed);
|
|||
|
|
b.gateMarkers[i]?.setAttribute('d',gateMarkerPathAt(gp,gate.side,sealed));
|
|||
|
|
b.gateMarkers[i]?.setAttribute('stroke',sealed?'#7d878e':color);
|
|||
|
|
b.gateMarkers[i]?.classList.toggle('sealed',sealed);
|
|||
|
|
});
|
|||
|
|
// Pointer dragging redraws the active polyline every frame. Its width was
|
|||
|
|
// already assigned synchronously above, so defer whole-world work until the
|
|||
|
|
if(b.drawing?.pointerId==null)queueLineWidthRefresh(b.id);else renderDragFrame(b);
|
|||
|
|
if(minimapDirty)scheduleMinimap();if(overviewDirty&&inWorldOverview())scheduleWorldOverview();
|
|||
|
|
perfEnd('renderBoardNow',renderStarted);
|
|||
|
|
}
|
|||
|
|
function activePath(b){return b.drawing?metaState(b.id).paths[b.drawing.pathIndex]||null:null}
|
|||
|
|
function nextPaint(){return new Promise(resolve=>requestAnimationFrame(()=>resolve()))}
|
|||
|
|
const activeCompletionVisuals=new Map();
|
|||
|
|
function finishCompletionVisual(boardId,skipped=false){
|
|||
|
|
const visual=activeCompletionVisuals.get(boardId);if(!visual)return false;activeCompletionVisuals.delete(boardId);clearTimeout(visual.timer);
|
|||
|
|
visual.board?.timers?.delete?.(visual.timer);visual.board?.card?.classList?.remove('completing');visual.flash?.remove?.();visual.burst?.remove?.();
|
|||
|
|
visual.resolve?.({skipped});return true;
|
|||
|
|
}
|
|||
|
|
function completionEffect(b,award){
|
|||
|
|
finishCompletionVisual(b.id,true);
|
|||
|
|
let resolve;const finished=new Promise(done=>{resolve=done}),visual={board:b,flash:null,burst:null,timer:0,resolve,finished};
|
|||
|
|
activeCompletionVisuals.set(b.id,visual);
|
|||
|
|
try{
|
|||
|
|
if(reducedMotionQuery?.matches){finishCompletionVisual(b.id,false);return visual}
|
|||
|
|
b.card.classList.add('completing');
|
|||
|
|
visual.flash=document.createElement('div');visual.flash.className='completion-flash';b.card.append(visual.flash);
|
|||
|
|
visual.burst=document.createElement('div');visual.burst.className='completion-burst';visual.burst.textContent=`◆ +${formatScore(award)}`;b.card.append(visual.burst);
|
|||
|
|
visual.timer=setTimeout(()=>finishCompletionVisual(b.id,false),1800);b.timers?.add?.(visual.timer);
|
|||
|
|
}catch(error){console.warn('BEND FIELD: completion effect skipped',error);finishCompletionVisual(b.id,true)}
|
|||
|
|
return visual;
|
|||
|
|
}
|
|||
|
|
function gemCollectionSources(b,count){
|
|||
|
|
const cells=b?.p?.valid||[],rect=b?.svg?.getBoundingClientRect?.(),sources=[];if(!cells.length||!rect?.width)return sources;
|
|||
|
|
const sx=rect.width/b.w,sy=rect.height/b.h,seed=b.meta.seed>>>0;
|
|||
|
|
for(let index=0;index<count;index++){const cell=cells[(Math.imul(index+1,17)+seed)%cells.length],jitterX=.2+((index*37)%61)/100,jitterY=.2+((index*29)%61)/100;sources.push([rect.left+(PAD+(cell[1]+jitterX)*CELL)*sx,rect.top+(PAD+(cell[0]+jitterY)*CELL)*sy])}
|
|||
|
|
return sources;
|
|||
|
|
}
|
|||
|
|
function playGemCollectionAnimation(b,award){
|
|||
|
|
const targetRect=scoreCountEl?.getBoundingClientRect?.();if(!b?.card?.isConnected||!targetRect?.width)return false;
|
|||
|
|
const reduced=reducedMotionQuery?.matches,count=reduced?4:Math.max(10,Math.min(18,8+Math.round(Math.log10(Math.max(10,award))*2))),target=[targetRect.left+targetRect.width/2,targetRect.top+targetRect.height/2],
|
|||
|
|
sources=gemCollectionSources(b,count);
|
|||
|
|
scoreCountEl.closest?.('.stat')?.classList.add('gem-receiving');
|
|||
|
|
let latest=0;
|
|||
|
|
sources.forEach(([x,y],index)=>{
|
|||
|
|
const particle=document.createElement('i');particle.className='gem-particle';particle.setAttribute('aria-hidden','true');particle.style.left=`${x}px`;particle.style.top=`${y}px`;document.body.append(particle);
|
|||
|
|
const dx=target[0]-x,dy=target[1]-y,arc=(index%2?-1:1)*(24+(index%5)*9),delay=reduced?0:index*48,duration=reduced?520:1450+(index%4)*110;latest=Math.max(latest,delay+duration);
|
|||
|
|
try{
|
|||
|
|
const flight=particle.animate([
|
|||
|
|
{transform:'translate3d(0,10px,0) scale(.25)',opacity:0},
|
|||
|
|
{transform:`translate3d(${arc*.18}px,${-18-Math.abs(arc)*.16}px,0) scale(1.28)`,opacity:1,offset:.16},
|
|||
|
|
{transform:`translate3d(${arc*.28}px,${-25-Math.abs(arc)*.12}px,0) scale(1.05)`,opacity:1,offset:.56},
|
|||
|
|
{transform:`translate3d(${dx*.48+arc}px,${dy*.3-Math.abs(arc)}px,0) scale(.95)`,opacity:1,offset:.76},
|
|||
|
|
{transform:`translate3d(${dx}px,${dy}px,0) scale(.26)`,opacity:.15}
|
|||
|
|
],{duration,delay,easing:'cubic-bezier(.2,.72,.18,1)',fill:'forwards'});
|
|||
|
|
flight.finished.catch(()=>{}).finally(()=>particle.remove());
|
|||
|
|
}catch(_){setTimeout(()=>particle.remove(),delay+duration)}
|
|||
|
|
});
|
|||
|
|
setTimeout(()=>scoreCountEl.closest?.('.stat')?.classList.remove('gem-receiving'),latest+120);return true;
|
|||
|
|
}
|
|||
|
|
function skipCompletionVisuals(){for(const boardId of[...activeCompletionVisuals.keys()])finishCompletionVisual(boardId,true)}
|
|||
|
|
document.addEventListener('keydown',event=>{if(event.key==='Escape')skipCompletionVisuals()},true);
|
|||
|
|
function updateSelectedProgress(b){if(b)selectedInfo.textContent=`\u30ec\u30d9\u30eb${b.meta.level}`}
|
|||
|
|
let selectedProgressFrame=0,pendingProgressBoard=null;
|
|||
|
|
function queueSelectedProgress(b){pendingProgressBoard=b;if(selectedProgressFrame)return;selectedProgressFrame=requestAnimationFrame(()=>{selectedProgressFrame=0;const pending=pendingProgressBoard;pendingProgressBoard=null;if(pending)updateSelectedProgress(pending)})}
|
|||
|
|
const expansionErrorCounts=new Map();
|
|||
|
|
function handleExpansionError(error){
|
|||
|
|
const message=error?.message||String(error),signature=`${error?.name||'Error'}:${message}`,count=(expansionErrorCounts.get(signature)||0)+1;
|
|||
|
|
expansionErrorCounts.set(signature,count);
|
|||
|
|
if(count%20===0)console.warn(`BEND FIELD: expansion is still retrying (${count})`,message);
|
|||
|
|
const workerDelay=error?.code==='WORKER_UNAVAILABLE'?Math.max(1000,workerRetryAt-Date.now()+50):700;scheduleExpansionRepair(workerDelay);
|
|||
|
|
}
|
|||
|
|
function checkSolvedWhenInteractionSettles(b){
|
|||
|
|
if(globalThis.document?.body?.classList?.contains?.('is-interacting')){requestAnimationFrame(()=>checkSolvedWhenInteractionSettles(b));return}
|
|||
|
|
checkSolvedAndExpand(b).catch(handleExpansionError);
|
|||
|
|
}
|
|||
|
|
const pendingBoardCommandSettlements=new Map();
|
|||
|
|
let boardCommandSettleFrame=0;
|
|||
|
|
function scheduleBoardCommandSettlement(b,{persist=false,solve=false,paint=true,invalidate=true,pathIndex=null}={}){
|
|||
|
|
if(!b)return;
|
|||
|
|
const pending=pendingBoardCommandSettlements.get(b.id)||{b,persist:false,solve:false,paint:false,invalidate:false,pathIndexes:new Set()};
|
|||
|
|
pending.persist||=persist;pending.solve||=solve;pending.paint||=paint;pending.invalidate||=invalidate;if(Number.isInteger(pathIndex))pending.pathIndexes.add(pathIndex);
|
|||
|
|
pendingBoardCommandSettlements.set(b.id,pending);
|
|||
|
|
if(boardCommandSettleFrame)return;
|
|||
|
|
boardCommandSettleFrame=requestAnimationFrame(()=>{
|
|||
|
|
boardCommandSettleFrame=0;
|
|||
|
|
if(document.body.classList.contains('is-interacting')){scheduleBoardCommandSettlement(b,{paint:false});return}
|
|||
|
|
const settlements=[...pendingBoardCommandSettlements.values()];pendingBoardCommandSettlements.clear();
|
|||
|
|
for(const item of settlements){
|
|||
|
|
const index=item.pathIndexes.size===1?[...item.pathIndexes][0]:null;
|
|||
|
|
if(item.invalidate)invalidateLineGraphCaches(item.b.id,index);
|
|||
|
|
if(item.paint&&item.b.card?.isConnected){renderBoard(item.b);updateSelectedProgress(item.b)}
|
|||
|
|
if(item.persist)changed(item.b.id,index);
|
|||
|
|
if(item.solve)checkSolvedWhenInteractionSettles(item.b);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
function applyBoardCommand(b,mutate,{persist=false,solve=false,paint=true,invalidate=true,deferSettlement=false}={}){
|
|||
|
|
const st=metaState(b.id),pathIndex=b.drawing?.pathIndex,pointerInteraction=b.drawing?.pointerId!=null,result=mutate(st);if(result===false)return false;
|
|||
|
|
if(pointerInteraction&&b.drawing?.pointerId==null)refreshInteractionState();
|
|||
|
|
touchBoardClaim(b.id);
|
|||
|
|
if(deferSettlement){scheduleBoardCommandSettlement(b,{persist,solve,paint,invalidate,pathIndex});return result??true}
|
|||
|
|
if(invalidate)invalidateLineGraphCaches(b.id,pathIndex);
|
|||
|
|
if(paint){renderBoard(b);updateSelectedProgress(b)}
|
|||
|
|
if(persist)changed(b.id,pathIndex);
|
|||
|
|
if(solve)checkSolvedWhenInteractionSettles(b);
|
|||
|
|
return result??true;
|
|||
|
|
}
|
|||
|
|
function generationFailureBonusKey(boardId){return`bend-field:generation-failure:${data.worldEpoch||WORLD_GENERATION}:${boardId}`}
|
|||
|
|
async function maybeGrantGenerationFailureBonus(meta,elapsedMs){
|
|||
|
|
if(!meta||elapsedMs<GENERATION_FAILURE_MIN_MS||!unresolvedExpansionCandidates(meta).length)return false;
|
|||
|
|
const marker=generationFailureBonusKey(meta.id);if(safeLocalGet(marker)==='1')return false;
|
|||
|
|
try{
|
|||
|
|
if(onlinePlayerEconomy()){
|
|||
|
|
const result=await fetchJson('/api/player/generation-bonus',{method:'POST',headers:cloudAuthHeaders(),body:JSON.stringify({boardId:meta.id})},10000);applyPlayerEconomyEnvelope(result);serverClockOffset=Number.isFinite(result.serverTime)?result.serverTime-Date.now():serverClockOffset;
|
|||
|
|
}else{
|
|||
|
|
data.bonusEvents=data.bonusEvents||{};data.bonusEvents[`generation-failure:${meta.id}`]=GENERATION_FAILURE_BONUS;data.bonusScore=bonusEventTotal();statsDirty=true;markGlobalDirty();await save(true);
|
|||
|
|
}
|
|||
|
|
safeLocalSet(marker,'1');updateHud();const board=rendered.get(meta.id);if(board?.card?.isConnected)playGemCollectionAnimation(board,GENERATION_FAILURE_BONUS);toast(`生成できなかった盤面の補償 ◆ +${formatScore(GENERATION_FAILURE_BONUS)}`,3200);return true;
|
|||
|
|
}catch(error){if(error?.status!==409)console.warn('BEND FIELD: generation failure bonus skipped',error);return false}
|
|||
|
|
}
|
|||
|
|
async function checkSolvedAndExpand(b){
|
|||
|
|
let st=metaState(b.id);if(st.solved||!isSolved(st,b.p))return false;
|
|||
|
|
if(cloudAvailable&&data.cloudProfile){
|
|||
|
|
await pullCloudWorld(true);st=metaState(b.id);if(st.solved){renderBoard(rendered.get(b.id));updateHud();return false}
|
|||
|
|
if(!isSolved(st,b.p))return false;
|
|||
|
|
}
|
|||
|
|
const previous={state:deepClone(st),lastSolveAt:data.lastSolveAt,timeAttack:deepClone(data.timeAttack),timeAttackRev:data.timeAttackRev,specialMechanicsSeen:[...(data.specialMechanicsSeen||[])],sealedSides:[...(b.meta.sealedSides||[])],metaRev:b.meta.rev};
|
|||
|
|
st.solved=true;hideSpecialTooltip();st.expanded=false;st.solvedBy=currentPlayerName();st.solvedById=currentPlayerId();st.solvedAt=trustedNow();st.scoreVersion=SCORE_VERSION;
|
|||
|
|
const reward=rewardDetailsForBoard(b.meta,st),award=Math.min(MAX_SCORE,reward.award);st.scoreAwarded=award;st.rewardIdentity=reward.identity;st.rewardCoefficient=reward.coefficient;
|
|||
|
|
maybeOpenStore(b.meta,st,award);recordTimeAttackScore(reward);st.rev=nextRevision();markStateDirty(b.id);
|
|||
|
|
const now=trustedNow();data.lastSolveAt=now;data.specialMechanicsSeen=[...new Set([...(data.specialMechanicsSeen||[]),...mechanicTypesForPuzzle(b.p)])].sort();markGlobalDirty();finalizeExitGates(b.meta);
|
|||
|
|
const immediateBoard=rendered.get(b.id);if(immediateBoard?.card?.isConnected){renderBoard(immediateBoard);playSound('clear');completionEffect(immediateBoard,award);playGemCollectionAnimation(immediateBoard,award)}updateHud();
|
|||
|
|
const persistence=save(true),preparation=prepareExpansionCandidate(b.meta).catch(error=>{if(globalThis.BEND_DEBUG_GENERATION)console.debug('BEND FIELD: speculative expansion preparation skipped',error);return null});
|
|||
|
|
if(!await persistence){
|
|||
|
|
finishCompletionVisual(b.id,true);if(data.metas[b.id]===b.meta&&data.states[b.id]===st){data.states[b.id]=previous.state;data.lastSolveAt=previous.lastSolveAt;data.timeAttack=previous.timeAttack;data.timeAttackRev=previous.timeAttackRev;data.specialMechanicsSeen=previous.specialMechanicsSeen;b.meta.sealedSides=previous.sealedSides;b.meta.rev=previous.metaRev}
|
|||
|
|
renderBoard(rendered.get(b.id));updateHud();throw new Error('保存できなかったため、クリアを取り消しました。');
|
|||
|
|
}
|
|||
|
|
if(cloudAvailable&&data.cloudProfile){
|
|||
|
|
const published=await pushCloudPending();
|
|||
|
|
if(!published){
|
|||
|
|
finishCompletionVisual(b.id,true);if(data.metas[b.id]===b.meta&&data.states[b.id]===st){data.states[b.id]=previous.state;data.lastSolveAt=previous.lastSolveAt;data.timeAttack=previous.timeAttack;data.timeAttackRev=previous.timeAttackRev;data.specialMechanicsSeen=previous.specialMechanicsSeen;b.meta.sealedSides=previous.sealedSides;b.meta.rev=previous.metaRev;markStateDirty(b.id);markMetaDirty(b.id);markGlobalDirty(false);await persistNow({skipCloud:true})}
|
|||
|
|
await pullCloudWorld(true);renderBoard(rendered.get(b.id));updateHud();toast('共有ワールドで先に更新されたため、最新状態を取得しました。');return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
let durableMeta=data.metas[b.id],durableState=data.states[b.id];if(!durableMeta||!durableState?.solved){scheduleExpansionRepair();return false}scheduleTimeAttackSuggestionAfterCompletion();
|
|||
|
|
const prepared=await preparation;durableMeta=data.metas[b.id];durableState=data.states[b.id];if(!durableMeta||!durableState?.solved){scheduleExpansionRepair();return false}
|
|||
|
|
const expansionStarted=perfNow();let made=0;try{made=await expandMeta(durableMeta,prepared)||0}catch(error){await maybeGrantGenerationFailureBonus(durableMeta,perfNow()-expansionStarted);handleExpansionError(error);return true}
|
|||
|
|
await maybeGrantGenerationFailureBonus(durableMeta,perfNow()-expansionStarted);
|
|||
|
|
if(cloudAvailable&&data.cloudProfile&&made){const shared=await pushCloudPending();if(!shared){await pullCloudWorld(true);scheduleExpansionRepair(500)}}
|
|||
|
|
scheduleExpansionRepair();if(made){expansionErrorCounts.clear();toast(`新しい盤面を${made}枚生成しました。`)}else scheduleExpansionRepair(350);return true;
|
|||
|
|
}
|
|||
|
|
function gateConnectEffect(b,gi,color){
|
|||
|
|
if(!b?.gateLayer||!Number.isInteger(gi))return;const gp=gatePoint(gateObj(b.p,gi));
|
|||
|
|
const pulse=svgEl('circle',{cx:gp[0],cy:gp[1],r:7,class:'gate-connect-pulse',stroke:color||'#fff'});
|
|||
|
|
b.gateLayer.append(pulse);boardTimeout(b,()=>pulse.remove(),720);
|
|||
|
|
b.card.classList.add('gate-connected');boardTimeout(b,()=>b.card.classList.remove('gate-connected'),320);
|
|||
|
|
try{navigator.vibrate?.(18)}catch(_){}
|
|||
|
|
}
|
|||
|
|
function reconcileDrawingPresentation(){refreshInteractionState();scheduleInteractionSettlePresentation()}
|
|||
|
|
function finalizeAtGate(b,gi){
|
|||
|
|
const st=metaState(b.id),pi=b.drawing?.pathIndex,path=pi!=null?st.paths[pi]:null;if(!path||gi==null||!path.detachedStart&&gi===path.startGate||usedGateSet(st,pi).has(gi))return false;
|
|||
|
|
const g=b.p.g[gi],last=path.cells[path.cells.length-1];if(g[0]!==last[0]||g[1]!==last[1])return false;
|
|||
|
|
const color=LINE_COLORS[(path.startColorIndex??path.colorIndex??0)%LINE_COLORS.length],done=applyBoardCommand(b,()=>{
|
|||
|
|
if(path.detachedStart){
|
|||
|
|
path.cells.reverse();path.startGate=gi;path.endGate=null;path.openGate=null;path.detachedStart=false;
|
|||
|
|
const gateColor=canonicalGateColorIndex(b.meta,gi,path);path.colorIndex=gateColor;path.startColorIndex=gateColor;path.endColorIndex=null;
|
|||
|
|
}else{path.endGate=gi;path.openGate=null;const inherited=neighborColor(b.meta,gi);path.endColorIndex=inherited??path.startColorIndex??path.colorIndex}
|
|||
|
|
b.drawing=null;b.armedGate=null;
|
|||
|
|
},{persist:true,solve:true,deferSettlement:true});
|
|||
|
|
if(done){playSound('gate');if(usesLightweightDragOverlay(b))requestAnimationFrame(()=>gateConnectEffect(b,gi,color));else gateConnectEffect(b,gi,color);queueMicrotask(reconcileDrawingPresentation)}return done;
|
|||
|
|
}
|
|||
|
|
function openTipMergePlan(b,ai,oi,otherSide='end',enteredOtherTipCell=false){
|
|||
|
|
const st=metaState(b.id);if(ai===oi||!Number.isInteger(ai)||!Number.isInteger(oi))return null;
|
|||
|
|
const a=st.paths[ai],o=st.paths[oi];
|
|||
|
|
if(!a?.cells?.length||!o?.cells?.length||a.endGate!=null||o.endGate!=null||otherSide==='start'&&!o.detachedStart||!a.detachedStart&&!o.detachedStart&&a.startGate===o.startGate)return null;
|
|||
|
|
const orientedOther=otherSide==='start'?[...o.cells].reverse():o.cells,al=a.cells[a.cells.length-1],ol=orientedOther[orientedOther.length-1],tipDistance=manhattan(al,ol);
|
|||
|
|
if(tipDistance!==0&&!(enteredOtherTipCell&&tipDistance===1))return null;
|
|||
|
|
const otherTail=[...orientedOther].reverse().map(cell=>[cell[0],cell[1]]);if(tipDistance===0)otherTail.shift();
|
|||
|
|
const joined=[...a.cells.map(cell=>[cell[0],cell[1]]),...otherTail],seen=new Set();
|
|||
|
|
let cells=joined,merged;
|
|||
|
|
for(const cell of cells){const key=ckey(...cell);if(seen.has(key)||!cellSet(b.p).has(key))return null;seen.add(key)}
|
|||
|
|
const aColor=a.startColorIndex??a.colorIndex,oStartColor=o.startColorIndex??o.colorIndex,oEndColor=o.endColorIndex??oStartColor,oRemainingColor=otherSide==='start'?oEndColor:oStartColor;
|
|||
|
|
if(!a.detachedStart&&!o.detachedStart)merged={startGate:a.startGate,endGate:o.startGate,openGate:null,cells,colorIndex:aColor,startColorIndex:aColor,endColorIndex:oStartColor};
|
|||
|
|
else if(!a.detachedStart&&o.detachedStart)merged={startGate:a.startGate,endGate:null,openGate:null,detachedStart:false,cells,colorIndex:aColor,startColorIndex:aColor,endColorIndex:oRemainingColor};
|
|||
|
|
else if(a.detachedStart&&!o.detachedStart){cells=[...joined].reverse();merged={startGate:o.startGate,endGate:null,openGate:null,detachedStart:false,cells,colorIndex:oStartColor,startColorIndex:oStartColor,endColorIndex:aColor}}
|
|||
|
|
else merged={startGate:a.startGate,endGate:null,openGate:null,detachedStart:true,cells,colorIndex:aColor,startColorIndex:aColor,endColorIndex:oRemainingColor};
|
|||
|
|
return{a,o,lo:Math.min(ai,oi),hi:Math.max(ai,oi),merged};
|
|||
|
|
}
|
|||
|
|
function joinTips(b,ai,oi,otherSide='end',enteredOtherTipCell=false){
|
|||
|
|
if(b?.joiningPaths)return false;const plan=openTipMergePlan(b,ai,oi,otherSide,enteredOtherTipCell);if(!plan)return false;
|
|||
|
|
const st=metaState(b.id),pointerId=b.drawing?.pointerId;b.joiningPaths=true;cancelBoardDragFrame(b);
|
|||
|
|
let done=false;
|
|||
|
|
try{done=applyBoardCommand(b,()=>{
|
|||
|
|
if(st.paths[ai]!==plan.a||st.paths[oi]!==plan.o)return false;
|
|||
|
|
st.paths.splice(plan.hi,1);st.paths.splice(plan.lo,1);st.paths.push(plan.merged);b.drawing=null;
|
|||
|
|
},{persist:true,solve:true,deferSettlement:true})}finally{b.joiningPaths=false}
|
|||
|
|
if(done){if(pointerId!=null)safeRelease(b.svg,pointerId);playSound('gate');queueMicrotask(reconcileDrawingPresentation)}
|
|||
|
|
return done;
|
|||
|
|
}
|
|||
|
|
function pathAxisAtCell(path,p,cell){
|
|||
|
|
const index=path?.cells?.findIndex(candidate=>sameCell(candidate,cell))??-1;if(index<0)return null;
|
|||
|
|
const previous=index?path.cells[index-1]:path.detachedStart?null:outsidePoint(gateObj(p,path.startGate)),next=index<path.cells.length-1?path.cells[index+1]:path.endGate!=null?outsidePoint(gateObj(p,path.endGate)):null;
|
|||
|
|
if(!previous||!next||isWarpTransition(p,previous,cell)||isWarpTransition(p,cell,next))return null;
|
|||
|
|
if(previous[0]===cell[0]&&next[0]===cell[0])return'H';
|
|||
|
|
if(previous[1]===cell[1]&&next[1]===cell[1])return'V';
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
function activateCrossing(b,cell,owner,last,deferRender=false,segmentOccupancy=null,suppressMotion=false){
|
|||
|
|
const key=ckey(...cell),st=metaState(b.id),pi=b.drawing?.pathIndex,path=pi==null?null:st.paths[pi];if(!path||!b.crossingKeySet.has(key))return false;
|
|||
|
|
const other=st.paths[owner],otherAxis=pathAxisAtCell(other,b.p,cell),approachAxis=last[0]===cell[0]?'H':last[1]===cell[1]?'V':null;
|
|||
|
|
if(!otherAxis||!approachAxis||otherAxis===approachAxis||pathIndexesAtCell(st,key).length!==1)return false;
|
|||
|
|
applyBoardCommand(b,()=>{path.cells.push(cell);path.openGate=null},{paint:!deferRender,persist:!deferRender,invalidate:!deferRender});
|
|||
|
|
if(segmentOccupancy)segmentOccupancy.set(key,pi);b.drawing?.pathCellIndex?.set(key,path.cells.length-1);playSound('gate');return true;
|
|||
|
|
}
|
|||
|
|
function extendOne(b,cell,deferRender=false,segmentOccupancy=null,suppressMotion=false){
|
|||
|
|
const activeBefore=activePath(b),lastBefore=activeBefore?.cells?.[activeBefore.cells.length-1];
|
|||
|
|
if(b.drawing?.warpDirection&&lastBefore){const dr=cell[0]-lastBefore[0],dc=cell[1]-lastBefore[1];if(dr!==b.drawing.warpDirection[0]||dc!==b.drawing.warpDirection[1])return false;b.drawing.warpDirection=null}
|
|||
|
|
|
|||
|
|
let st=metaState(b.id),path=activePath(b),key=ckey(...cell),occupancyMap=segmentOccupancy||b.drawing?.segmentOccupancy||occupiedMap(st);
|
|||
|
|
if(!path||!cellSet(b.p).has(key))return false;
|
|||
|
|
const pi=b.drawing.pathIndex,last=path.cells[path.cells.length-1];
|
|||
|
|
if(sameCell(cell,last))return true;
|
|||
|
|
if(!pathCellsAdjacent(b.p,cell,last))return false;
|
|||
|
|
const occ=occupancyMap,isCrossing=b.crossingKeySet.has(key),owners=isCrossing?pathIndexesAtCell(st,key):null,currentOwner=isCrossing?(owners.includes(pi)?pi:occ.get(key)):occ.get(key),owner=isCrossing?owners.find(index=>index!==pi):(currentOwner!==pi?currentOwner:null);
|
|||
|
|
if(currentOwner===pi){
|
|||
|
|
let rewind=b.drawing?.pathCellIndex?.get(key)??path.cells.findIndex(candidate=>sameCell(candidate,cell));
|
|||
|
|
if(rewind<0)return false;
|
|||
|
|
const pair=warpPairForCell(b.p,path.cells[rewind]),pairIndex=pair?(b.drawing?.pathCellIndex?.get(ckey(...pair))??path.cells.findIndex(candidate=>sameCell(candidate,pair))):-1;
|
|||
|
|
if(pairIndex===rewind+1)rewind--;
|
|||
|
|
if(rewind<0||path.detachedStart&&rewind===0)return false;
|
|||
|
|
const removed=path.cells.slice(rewind+1),removedWarp=removed.some((candidate,index)=>index&&isWarpTransition(b.p,removed[index-1],candidate));
|
|||
|
|
if(removedWarp&&b.drawing?.pointerId!=null)b.warpedDuringExtend=true;
|
|||
|
|
const result=applyBoardCommand(b,()=>{
|
|||
|
|
path.cells.splice(rewind+1);
|
|||
|
|
if(path.openGate!=null&&!sameCell(path.cells[path.cells.length-1],[b.p.g[path.openGate][0],b.p.g[path.openGate][1]]))path.openGate=null;
|
|||
|
|
},{paint:!deferRender,persist:!deferRender,invalidate:!deferRender});
|
|||
|
|
if(result&&occupancyMap)for(const removedCell of removed){
|
|||
|
|
const removedKey=ckey(...removedCell),remaining=b.crossingKeySet.has(removedKey)?pathIndexesAtCell(st,removedKey).find(index=>index!==pi):null;
|
|||
|
|
if(remaining==null)occupancyMap.delete(removedKey);else occupancyMap.set(removedKey,remaining);
|
|||
|
|
b.drawing?.pathCellIndex?.delete(removedKey);
|
|||
|
|
}
|
|||
|
|
return Boolean(result);
|
|||
|
|
}
|
|||
|
|
const exitDirection=crossingExitDirection(st,b.p,pi);
|
|||
|
|
if(exitDirection){const direction=[cell[0]-last[0],cell[1]-last[1]];if(direction[0]!==exitDirection[0]||direction[1]!==exitDirection[1])return false}
|
|||
|
|
if(owner!=null){
|
|||
|
|
if(activateCrossing(b,cell,owner,last,deferRender,segmentOccupancy,suppressMotion))return true;
|
|||
|
|
const other=st.paths[owner];
|
|||
|
|
if(other?.endGate==null){
|
|||
|
|
const otherSide=other.detachedStart&&sameCell(other.cells[0],cell)?'start':sameCell(other.cells[other.cells.length-1],cell)?'end':null;
|
|||
|
|
if(otherSide)return joinTips(b,pi,owner,otherSide,true);
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
const lock=lockForDoor(b.p,cell);
|
|||
|
|
if(lock&&!pathHasLockKey(path,lock))return false;
|
|||
|
|
const warpExit=warpPairForCell(b.p,cell);
|
|||
|
|
if(warpExit){
|
|||
|
|
const exitKey=ckey(...warpExit),exitOwner=occ.get(exitKey);if(exitOwner!=null&&exitOwner!==pi||path.cells.some(candidate=>sameCell(candidate,warpExit)))return false;
|
|||
|
|
const pointerId=b.drawing?.pointerId;
|
|||
|
|
const beforeWarp=path.cells[path.cells.length-1],warpDirection=[cell[0]-beforeWarp[0],cell[1]-beforeWarp[1]];
|
|||
|
|
applyBoardCommand(b,()=>{path.cells.push(cell,[...warpExit]);path.openGate=null},{paint:!deferRender,persist:!deferRender,invalidate:!deferRender});
|
|||
|
|
if(segmentOccupancy){segmentOccupancy.set(key,pi);segmentOccupancy.set(exitKey,pi)}if(b.drawing?.pathCellIndex){b.drawing.pathCellIndex.set(key,path.cells.length-2);b.drawing.pathCellIndex.set(exitKey,path.cells.length-1)}
|
|||
|
|
if(pointerId!=null)b.warpedDuringExtend=true;if(b.drawing)b.drawing.warpDirection=warpDirection
|
|||
|
|
const gi=gateAtCell(b.p,warpExit,path.detachedStart?null:path.startGate);if(gi!=null&&!usedGateSet(st,pi).has(gi))return finalizeAtGate(b,gi);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
applyBoardCommand(b,()=>{path.cells.push(cell);path.openGate=null},{paint:!deferRender,persist:!deferRender,invalidate:!deferRender});
|
|||
|
|
if(segmentOccupancy)segmentOccupancy.set(key,pi);b.drawing?.pathCellIndex?.set(key,path.cells.length-1);
|
|||
|
|
if(!suppressMotion){flashConfirmedCell(b,cell);playConfirmTone(path.cells.length)}
|
|||
|
|
const gi=gateAtCell(b.p,cell,path.detachedStart?null:path.startGate);
|
|||
|
|
if(gi!=null&&!usedGateSet(st,pi).has(gi))return finalizeAtGate(b,gi);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
function extendToward(b,target){
|
|||
|
|
const path=activePath(b);if(!path||!target)return false;
|
|||
|
|
const last=path.cells[path.cells.length-1];
|
|||
|
|
if(sameCell(last,target))return true;
|
|||
|
|
// A direct cell click may only advance to a neighboring cell. Long pointer
|
|||
|
|
// movement is handled by segment traversal below, never by a guessed route.
|
|||
|
|
if(manhattan(last,target)!==1)return false;
|
|||
|
|
return extendOne(b,target);
|
|||
|
|
}
|
|||
|
|
function pathTailAxis(path,p){
|
|||
|
|
if(!path?.cells?.length)return null;
|
|||
|
|
const last=path.cells[path.cells.length-1],previous=path.cells.length>1?path.cells[path.cells.length-2]:path.detachedStart?null:outsidePoint(gateObj(p,path.startGate));if(!previous)return null;
|
|||
|
|
if(isWarpTransition(p,previous,last))return null;
|
|||
|
|
return previous[0]===last[0]?'H':previous[1]===last[1]?'V':null;
|
|||
|
|
}
|
|||
|
|
function cellsCrossedBySegment(from,to,{preferredAxis=null,validCells=null}={}){
|
|||
|
|
const x0=(from[0]-PAD)/CELL,y0=(from[1]-PAD)/CELL,x1=(to[0]-PAD)/CELL,y1=(to[1]-PAD)/CELL;
|
|||
|
|
let c=Math.floor(x0),r=Math.floor(y0);
|
|||
|
|
const endC=Math.floor(x1),endR=Math.floor(y1),dc=x1-x0,dr=y1-y0,sx=Math.sign(dc),sy=Math.sign(dr);
|
|||
|
|
const deltaC=sx?1/Math.abs(dc):Infinity,deltaR=sy?1/Math.abs(dr):Infinity;
|
|||
|
|
let maxC=sx>0?(c+1-x0)/Math.abs(dc):sx<0?(x0-c)/Math.abs(dc):Infinity,
|
|||
|
|
maxR=sy>0?(r+1-y0)/Math.abs(dr):sy<0?(y0-r)/Math.abs(dr):Infinity;
|
|||
|
|
const cells=[],limit=600;
|
|||
|
|
for(let guard=0;guard<limit&&(c!==endC||r!==endR);guard++){
|
|||
|
|
const corner=Math.abs(maxC-maxR)<1e-9;
|
|||
|
|
if(corner){
|
|||
|
|
const horizontal=[r,c+sx],vertical=[r+sy,c],horizontalValid=!validCells||validCells.has(ckey(...horizontal)),verticalValid=!validCells||validCells.has(ckey(...vertical));
|
|||
|
|
const chooseHorizontal=horizontalValid&&!verticalValid||horizontalValid===verticalValid&&(preferredAxis==='H'||preferredAxis!=='V'&&Math.abs(dc)>=Math.abs(dr));
|
|||
|
|
if(chooseHorizontal){c+=sx;maxC+=deltaC}else{r+=sy;maxR+=deltaR}
|
|||
|
|
}else if(maxC<maxR){c+=sx;maxC+=deltaC}else{r+=sy;maxR+=deltaR}
|
|||
|
|
cells.push([r,c]);
|
|||
|
|
}
|
|||
|
|
return cells;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function gridCellAt(b,pt){
|
|||
|
|
if(!pt)return null;const c=Math.floor((pt[0]-PAD)/CELL),r=Math.floor((pt[1]-PAD)/CELL);
|
|||
|
|
return r>=0&&c>=0&&r<b.p.bounds.h&&c<b.p.bounds.w?[r,c]:null;
|
|||
|
|
}
|
|||
|
|
function rewindActivePathToCell(b,target,segmentOccupancy){
|
|||
|
|
const path=activePath(b);if(!path||!target)return false;
|
|||
|
|
const drawing=b.drawing,key=ckey(...target);let index=drawing?.pathCellIndex?.get(key)??path.cells.findIndex(cell=>sameCell(cell,target));if(index<0||index===path.cells.length-1)return false;
|
|||
|
|
const pair=warpPairForCell(b.p,path.cells[index]),pairIndex=pair?(drawing?.pathCellIndex?.get(ckey(...pair))??path.cells.findIndex(cell=>sameCell(cell,pair))):-1;
|
|||
|
|
if(pairIndex===index+1)index--;if(index<0||path.detachedStart&&index===0)return false;
|
|||
|
|
const removed=path.cells.slice(index+1),removedWarp=removed.some((cell,removedIndex)=>removedIndex&&isWarpTransition(b.p,removed[removedIndex-1],cell));
|
|||
|
|
const result=applyBoardCommand(b,()=>{path.cells.splice(index+1);if(path.openGate!=null&&!sameCell(path.cells[path.cells.length-1],[b.p.g[path.openGate][0],b.p.g[path.openGate][1]]))path.openGate=null},{paint:false,persist:false,invalidate:false});
|
|||
|
|
if(!result)return false;if(removedWarp&&drawing?.pointerId!=null)b.warpedDuringExtend=true;
|
|||
|
|
for(const removedCell of removed){const removedKey=ckey(...removedCell),remaining=b.crossingKeySet.has(removedKey)?pathIndexesAtCell(metaState(b.id),removedKey).find(owner=>owner!==drawing.pathIndex):null;if(remaining==null)segmentOccupancy?.delete(removedKey);else segmentOccupancy?.set(removedKey,remaining);drawing?.pathCellIndex?.delete(removedKey)}
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
function removeDetachedPathAtOwnEndpoint(b,point,pixels=24){
|
|||
|
|
const drawing=b?.drawing,st=drawing&&metaState(b.id),path=st?.paths?.[drawing.pathIndex];
|
|||
|
|
if(!point||!path?.detachedStart||path.endGate!=null||path.cells.length<2)return false;
|
|||
|
|
const fixed=boardCellCenter(path.cells[0]),radius=Math.min(pointerLocalRadius(b,pixels),CELL*.6);
|
|||
|
|
if((fixed[0]-point[0])**2+(fixed[1]-point[1])**2>radius*radius)return false;
|
|||
|
|
const pointerId=drawing.pointerId,index=drawing.pathIndex;cancelBoardDragFrame(b);
|
|||
|
|
const done=applyBoardCommand(b,()=>{if(st.paths[index]!==path)return false;st.paths.splice(index,1);b.drawing=null;b.armedGate=null},{persist:true,solve:true,deferSettlement:true});
|
|||
|
|
if(done){if(pointerId!=null)safeRelease(b.svg,pointerId);playSound('reset');queueMicrotask(reconcileDrawingPresentation)}
|
|||
|
|
return Boolean(done);
|
|||
|
|
}
|
|||
|
|
function extendPointerTo(b,rawPoint,deferFrameRender=false,catchupLimit=DRAG_MAX_CATCHUP_CELLS){
|
|||
|
|
const drawing=b.drawing,path=activePath(b);if(!drawing||!path||!rawPoint)return;
|
|||
|
|
const pathIndex=drawing.pathIndex;b.specialCrossTriggered=false;b.warpedDuringExtend=false;
|
|||
|
|
const mappedPoint=pointerPointForDrawing(drawing,rawPoint);updateDrawingPointerState(b,drawing,mappedPoint);
|
|||
|
|
const point=mappedPoint,targetCell=gridCellAt(b,point),candidate=drawing.currentCandidateCell,probeStep=6,probeKey=`${drawing.currentConfirmedCell?.join(',')||''}|${targetCell?.join(',')||''}|${candidate?.join(',')||''}|${Math.round(point[0]/probeStep)},${Math.round(point[1]/probeStep)}`;
|
|||
|
|
if(drawing.lastModelProbeKey===probeKey&&!drawing.catchupPending)return;drawing.lastModelProbeKey=probeKey;drawing.catchupPending=false;
|
|||
|
|
const from=boardCellCenter(path.cells[path.cells.length-1]),segmentOccupancy=drawing.segmentOccupancy||(drawing.segmentOccupancy=occupiedMap(metaState(b.id)));let changedPath=false,blocked=false,feedbackCell=null,feedbackLength=0;
|
|||
|
|
const emitBatchedFeedback=()=>{if(!feedbackCell)return;const now=perfNow();if(now-(b.lastDragToneAt||0)>=90){b.lastDragToneAt=now;playConfirmTone(feedbackLength)}if(uiSettings.lightweightRendering||autoReducedEffects||now-(b.lastDragFeedbackAt||0)<180)return;b.lastDragFeedbackAt=now;flashConfirmedCell(b,feedbackCell)};
|
|||
|
|
updateGateSnapArming(b,drawing);
|
|||
|
|
if(removeDetachedPathAtOwnEndpoint(b,point)){queueSelectedProgress(b);return}
|
|||
|
|
const overlap=overlappingOpenTipAtPoint(b,point);
|
|||
|
|
if(overlap&&joinTips(b,pathIndex,overlap.index,overlap.side)){queueSelectedProgress(b);return}
|
|||
|
|
if(targetCell&&(drawing.pathCellIndex?.has(ckey(...targetCell))||path.cells.some(cell=>sameCell(cell,targetCell))))changedPath=rewindActivePathToCell(b,targetCell,segmentOccupancy)||changedPath;
|
|||
|
|
else{
|
|||
|
|
const crossed=cellsCrossedBySegment(from,point,{preferredAxis:pathTailAxis(activePath(b),b.p),validCells:cellSet(b.p)}),
|
|||
|
|
batch=Number.isFinite(catchupLimit)?crossed.slice(0,Math.max(1,catchupLimit)):crossed;
|
|||
|
|
let catchupHalted=false;
|
|||
|
|
for(const cell of batch){
|
|||
|
|
const key=ckey(...cell),active=activePath(b);if(!active){catchupHalted=true;break}
|
|||
|
|
if(!cellSet(b.p).has(key)){blocked=true;continue}
|
|||
|
|
if(blocked){if(active.cells.some(existing=>sameCell(existing,cell)))changedPath=rewindActivePathToCell(b,cell,segmentOccupancy)||changedPath;continue}
|
|||
|
|
if(!extendOne(b,cell,true,segmentOccupancy,true)){catchupHalted=true;break}
|
|||
|
|
changedPath=true;const updated=activePath(b)||active,tip=updated?.cells?.[updated.cells.length-1];feedbackCell=tip?[...tip]:[...cell];feedbackLength=updated?.cells?.length||1;if(b.specialCrossTriggered||b.warpedDuringExtend)break;
|
|||
|
|
}
|
|||
|
|
if(b.drawing&&crossed.length>batch.length&&!catchupHalted&&!blocked&&!b.specialCrossTriggered&&!b.warpedDuringExtend){b.drawing.catchupPending=true;perfCount('pickupCatchupBatches');perfGauge('pickupCatchupCellsRemaining',crossed.length-batch.length)}
|
|||
|
|
}
|
|||
|
|
if(b.warpedDuringExtend&&b.drawing){
|
|||
|
|
const active=activePath(b),tip=active?.cells?.[active.cells.length-1];
|
|||
|
|
if(tip){const exitPoint=boardCellCenter(tip);b.drawing.pointerOffset=[exitPoint[0]-rawPoint[0],exitPoint[1]-rawPoint[1]];b.drawing.lastPoint=exitPoint;syncDrawingConfirmedState(b,b.drawing)}
|
|||
|
|
if(changedPath){b.dragSpecialRevision++;if(b.drawing)b.drawing.logicalRevision=(b.drawing.logicalRevision||0)+1}
|
|||
|
|
emitBatchedFeedback();
|
|||
|
|
if(!deferFrameRender||b.drawing?.pointerId==null){if(b.drawing?.pointerId!=null)renderDragFrame(b);else renderBoard(b)}if(changedPath)queueSelectedProgress(b);return;
|
|||
|
|
}
|
|||
|
|
if(b.drawing){
|
|||
|
|
updateGateSnapArming(b,drawing);
|
|||
|
|
const active=activePath(b),tip=active?.cells?.[active.cells.length-1]||null;let snappedGate=gateFromPointOrCell(b,mappedPoint);
|
|||
|
|
if(active&&tip){const tipGate=gateFromCell(b,tip,mappedPoint,active.detachedStart?null:active.startGate,20);if(Number.isInteger(tipGate))snappedGate=tipGate}
|
|||
|
|
if(active&&!active.detachedStart&&snappedGate===active.startGate&&active.openGate!==snappedGate)snappedGate=null;
|
|||
|
|
if(active&&snappedGate===drawing.originGate&&!drawing.leftOriginGateCell)snappedGate=null;
|
|||
|
|
if(active&&Number.isInteger(snappedGate)){
|
|||
|
|
const gateCell=[b.p.g[snappedGate][0],b.p.g[snappedGate][1]],currentTip=active.cells[active.cells.length-1];
|
|||
|
|
if(sameCell(gateCell,currentTip)){const snap=boardPointForGate(b,snappedGate).point;b.drawing.lastPoint=snap;finalizeAtGate(b,snappedGate)}
|
|||
|
|
else b.drawing.lastPoint=point;
|
|||
|
|
}else b.drawing.lastPoint=point;
|
|||
|
|
if(b.drawing){syncDrawingConfirmedState(b,b.drawing);updateDrawingPointerState(b,b.drawing,mappedPoint)}
|
|||
|
|
}
|
|||
|
|
if(changedPath){b.dragSpecialRevision++;if(b.drawing)b.drawing.logicalRevision=(b.drawing.logicalRevision||0)+1}
|
|||
|
|
emitBatchedFeedback();
|
|||
|
|
if(!deferFrameRender||b.drawing?.pointerId==null){if(b.drawing?.pointerId!=null)renderDragFrame(b);else renderBoard(b)}if(changedPath)queueSelectedProgress(b)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
function reopenConnectedFromGate(b,pi,gi,pointerId=null){
|
|||
|
|
const st=metaState(b.id),path=st.paths[pi];
|
|||
|
|
if(!path||path.endGate==null)return false;
|
|||
|
|
if(gi!==path.endGate&&gi!==path.startGate)return false;
|
|||
|
|
const openedColor=canonicalGateColorIndex(b.meta,gi,path);
|
|||
|
|
return applyBoardCommand(b,()=>{
|
|||
|
|
if(gi===path.endGate){
|
|||
|
|
path.endColorIndex=openedColor;path.endGate=null;path.openGate=gi;
|
|||
|
|
}else{
|
|||
|
|
const oldStart=path.startGate,oldEnd=path.endGate,oldEndColor=canonicalGateColorIndex(b.meta,oldEnd,path);
|
|||
|
|
path.cells=[...path.cells].reverse();path.startGate=oldEnd;path.endGate=null;path.openGate=oldStart;
|
|||
|
|
path.startColorIndex=oldEndColor;path.colorIndex=oldEndColor;path.endColorIndex=openedColor;
|
|||
|
|
}
|
|||
|
|
b.armedGate=gi;b.drawing=drawingFromGate(b,pi,gi,pointerId);
|
|||
|
|
},{persist:pointerId==null,paint:pointerId==null});
|
|||
|
|
}
|
|||
|
|
function reopenPathAtCell(b,pi,cell,pointerId=null){
|
|||
|
|
const st=metaState(b.id),path=st.paths[pi];
|
|||
|
|
if(!path)return false;
|
|||
|
|
const idx=path.cells.findIndex(c=>sameCell(c,cell));
|
|||
|
|
if(idx<0)return false;
|
|||
|
|
return applyBoardCommand(b,()=>{
|
|||
|
|
if(path.endGate==null)path.cells.splice(idx+1);
|
|||
|
|
else{const n=path.cells.length,distFromStart=idx,distFromEnd=n-1-idx;if(distFromEnd<=distFromStart){path.cells.splice(idx+1);path.endGate=null;path.endColorIndex=null}else{const oldEnd=path.endGate,oldEndColor=path.endColorIndex,reversed=[...path.cells].reverse(),newIdx=n-1-idx;path.cells=reversed.slice(0,newIdx+1);path.startGate=oldEnd;path.endGate=null;path.startColorIndex=oldEndColor??path.colorIndex;path.colorIndex=path.startColorIndex;path.endColorIndex=null}}
|
|||
|
|
b.drawing=drawingForPath(b,pi,pointerId,null);
|
|||
|
|
},{persist:pointerId==null,paint:pointerId==null});
|
|||
|
|
}
|
|||
|
|
function reverseDetachedPath(path){
|
|||
|
|
if(!path?.detachedStart)return false;
|
|||
|
|
path.cells.reverse();const startColor=path.startColorIndex??path.colorIndex??0,endColor=path.endColorIndex??startColor;
|
|||
|
|
path.startColorIndex=endColor;path.colorIndex=endColor;path.endColorIndex=startColor;path.openGate=null;return true;
|
|||
|
|
}
|
|||
|
|
function detachPathFromStartGate(b,pi,gi,pointerId=null){
|
|||
|
|
const path=metaState(b.id).paths[pi];if(!path||path.detachedStart||path.endGate!=null||path.startGate!==gi||path.cells.length<2)return false;
|
|||
|
|
return applyBoardCommand(b,()=>{
|
|||
|
|
path.cells.reverse();const startColor=path.startColorIndex??path.colorIndex??0,endColor=path.endColorIndex??startColor;
|
|||
|
|
path.startColorIndex=endColor;path.colorIndex=endColor;path.endColorIndex=startColor;path.detachedStart=true;path.openGate=gi;
|
|||
|
|
b.armedGate=gi;b.drawing=drawingFromGate(b,pi,gi,pointerId);
|
|||
|
|
},{persist:pointerId==null,paint:pointerId==null});
|
|||
|
|
}
|
|||
|
|
function orientDetachedEndpoint(b,pi,side,pointerId=null,point=null){
|
|||
|
|
const path=metaState(b.id).paths[pi];if(!path?.detachedStart)return false;
|
|||
|
|
if(side==='start')applyBoardCommand(b,()=>reverseDetachedPath(path),{persist:pointerId==null,paint:pointerId==null});
|
|||
|
|
b.armedGate=null;b.drawing=drawingForPath(b,pi,pointerId,point);return true;
|
|||
|
|
}
|
|||
|
|
function startGate(b,gi,pointerId=null){
|
|||
|
|
const st=metaState(b.id);playSound('grab');
|
|||
|
|
if(st.solved||!Number.isInteger(gi)||!gateConnectionAllowed(b.meta,gi))return;
|
|||
|
|
|
|||
|
|
const existing=st.paths.findIndex(path=>pathUsesGate(path,gi));
|
|||
|
|
if(existing>=0){
|
|||
|
|
const path=st.paths[existing],gateColor=canonicalGateColorIndex(b.meta,gi,path);setPathColorAtGate(path,gi,gateColor);
|
|||
|
|
if(path.endGate!=null){if(reopenConnectedFromGate(b,existing,gi,pointerId)&&pointerId==null)renderBoardNow(b);return}
|
|||
|
|
if(!path.detachedStart&&path.startGate===gi&&path.cells.length>1){if(detachPathFromStartGate(b,existing,gi,pointerId)&&pointerId==null)renderBoardNow(b);return}
|
|||
|
|
if(path.openGate===gi){
|
|||
|
|
path.endColorIndex=canonicalGateColorIndex(b.meta,gi,path);b.armedGate=gi;b.drawing=drawingFromGate(b,existing,gi,pointerId);if(pointerId==null)renderBoardNow(b);
|
|||
|
|
}else if(path.cells.length===1){b.drawing=drawingFromGate(b,existing,gi,pointerId);if(pointerId==null)renderBoardNow(b)}
|
|||
|
|
else toast('\u7dda\u306e\u5148\u7aef\u3092\u3064\u304b\u3093\u3067\u304f\u3060\u3055\u3044\u3002');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const g=gateObj(b.p,gi),occ=occupiedMap(st);
|
|||
|
|
if(occ.has(ckey(...g.cell))){renderBoard(b);return}
|
|||
|
|
|
|||
|
|
const colorIndex=canonicalGateColorIndex(b.meta,gi,null);
|
|||
|
|
const started=applyBoardCommand(b,()=>{
|
|||
|
|
st.paths.push({startGate:gi,endGate:null,openGate:null,cells:[[g.cell[0],g.cell[1]]],colorIndex,startColorIndex:colorIndex,endColorIndex:null});
|
|||
|
|
b.drawing=drawingFromGate(b,st.paths.length-1,gi,pointerId);
|
|||
|
|
b.drawing.createdPath=pointerId!=null;
|
|||
|
|
},{persist:pointerId==null,paint:pointerId==null});
|
|||
|
|
if(started&&pointerId==null)renderBoardNow(b);
|
|||
|
|
}
|
|||
|
|
function safeCapture(el,id){try{el.setPointerCapture(id)}catch(_){}finally{refreshInteractionState()}}
|
|||
|
|
function safeRelease(el,id){try{if(el.hasPointerCapture?.(id))el.releasePointerCapture(id)}catch(_){}finally{refreshInteractionState();scheduleInteractionSettlePresentation()}}
|
|||
|
|
function eventToSvg(b,e){
|
|||
|
|
const r=boardScreenRect(b),vb=b.svg.viewBox.baseVal;
|
|||
|
|
if(!r.width||!r.height)return null;
|
|||
|
|
return[(e.clientX-r.left)*vb.width/r.width,(e.clientY-r.top)*vb.height/r.height]
|
|||
|
|
}
|
|||
|
|
function cellAt(b,pt){if(!pt)return null;const c=Math.floor((pt[0]-PAD)/CELL),r=Math.floor((pt[1]-PAD)/CELL);return cellSet(b.p).has(ckey(r,c))?[r,c]:null}
|
|||
|
|
function setActiveBoard(id){const previous=rendered.get(activeBoard);let endedDrawing=false;activeBoard=id;if(previous&&previous.id!==id&&previous.drawing?.pointerId==null){endedDrawing=Boolean(previous.drawing);previous.drawing=null;previous.armedGate=null;if(endedDrawing)renderBoard(previous)}if(endedDrawing)queueMicrotask(reconcileDrawingPresentation);return previous}
|
|||
|
|
function selectBoard(b,{paint=true}={}){
|
|||
|
|
if(!b)return;
|
|||
|
|
const alreadySelected=activeBoard===b.id&&hudBoardId===b.id&&data.selectedBoardId===b.id;
|
|||
|
|
const previousHud=rendered.get(hudBoardId);setActiveBoard(b.id);hudBoardId=b.id;data.selectedBoardId=b.id;markGlobalDirty(false);
|
|||
|
|
if(previousHud&&previousHud!==b){previousHud.card.classList.remove('hud-current');if(paint)renderBoard(previousHud)}
|
|||
|
|
b.card.classList.add('hud-current');if(!alreadySelected&&paint)renderBoard(b);updateSelectedProgress(b)
|
|||
|
|
}
|
|||
|
|
function metasAreAdjacent(a,b){
|
|||
|
|
if(!a?.chunks||!b?.chunks||a.id===b.id)return false;
|
|||
|
|
const cells=new Set(b.chunks.map(([dx,dy])=>key2(b.x+dx,b.y+dy)));
|
|||
|
|
for(const[dx,dy]of a.chunks){
|
|||
|
|
const x=a.x+dx,y=a.y+dy;
|
|||
|
|
if(cells.has(key2(x+1,y))||cells.has(key2(x-1,y))||cells.has(key2(x,y+1))||cells.has(key2(x,y-1)))return true;
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
function shouldTeleportToUnsolvedBoard(_meta){return false}
|
|||
|
|
function pointerLocalRadius(_board,pixels=50){return pixels/Math.max(MIN_CAMERA_SCALE,cam.scale)}
|
|||
|
|
function pointerFacesGate(b,index,point){
|
|||
|
|
const gate=gateObj(b.p,index),center=boardCellCenter(gate.cell),[dr,dc]=SIDE_D[gate.side];
|
|||
|
|
return(point[0]-center[0])*dc+(point[1]-center[1])*dr>=CELL*.06;
|
|||
|
|
}
|
|||
|
|
function gateCandidatesInCell(b,cell,{exclude=null,interactiveOnly=true}={}){
|
|||
|
|
if(!cell)return[];
|
|||
|
|
const candidates=[],indexes=b.gateIndexesByCell?(b.gateIndexesByCell.get(ckey(...cell))||[]):b.p.g.keys();
|
|||
|
|
for(const index of indexes){
|
|||
|
|
const gate=b.p.g[index];
|
|||
|
|
if(index===exclude||gate[0]!==cell[0]||gate[1]!==cell[1])continue;
|
|||
|
|
if(interactiveOnly&&!gateConnectionAllowed(b.meta,index))continue;
|
|||
|
|
candidates.push(index);
|
|||
|
|
}
|
|||
|
|
return candidates;
|
|||
|
|
}
|
|||
|
|
function gateCandidateAtPoint(b,point,{cellHint=null,directGate=null,exclude=null,interactiveOnly=true,maxPixels=null,requireFacing=false}={}){
|
|||
|
|
if(!point)return Number.isInteger(directGate)?directGate:null;
|
|||
|
|
const cell=cellHint||cellAt(b,point),inCell=gateCandidatesInCell(b,cell,{exclude,interactiveOnly});
|
|||
|
|
if(Number.isInteger(directGate)&&directGate!==exclude&&b.p.g[directGate]&&(!interactiveOnly||gateConnectionAllowed(b.meta,directGate)))return directGate;
|
|||
|
|
let candidates=inCell;
|
|||
|
|
if(!candidates.length){
|
|||
|
|
if(maxPixels===0)return null;
|
|||
|
|
const radius=pointerLocalRadius(b,Number.isFinite(maxPixels)?maxPixels:34),limit=radius*radius;
|
|||
|
|
candidates=[];
|
|||
|
|
for(let index=0;index<b.p.g.length;index++){
|
|||
|
|
if(index===exclude||interactiveOnly&&!gateConnectionAllowed(b.meta,index)||requireFacing&&!pointerFacesGate(b,index,point))continue;
|
|||
|
|
const gp=gatePoint(gateObj(b.p,index)),distance=(gp[0]-point[0])**2+(gp[1]-point[1])**2;
|
|||
|
|
if(distance<=limit)candidates.push(index);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if(!candidates.length)return null;
|
|||
|
|
if(candidates.length===1)return candidates[0];
|
|||
|
|
let best=candidates[0],bestDistance=Infinity;
|
|||
|
|
for(const index of candidates){
|
|||
|
|
const gp=gatePoint(gateObj(b.p,index)),distance=(gp[0]-point[0])**2+(gp[1]-point[1])**2;
|
|||
|
|
if(distance<bestDistance){best=index;bestDistance=distance}
|
|||
|
|
}
|
|||
|
|
return best;
|
|||
|
|
}
|
|||
|
|
function rebuildEndpointIndexes(b,st=metaState(b.id)){
|
|||
|
|
const indexByCell=new Map(),add=(cell,index,side)=>{if(!cell)return;const key=ckey(...cell),entries=indexByCell.get(key)||[];entries.push({index,side,cell});indexByCell.set(key,entries)};
|
|||
|
|
st.paths.forEach((path,index)=>{if(path?.endGate!=null||!path?.cells?.length)return;add(path.cells[path.cells.length-1],index,'end');if(path.detachedStart&&path.cells.length>1)add(path.cells[0],index,'start')});
|
|||
|
|
b.endpointIndexesByCell=indexByCell;return indexByCell;
|
|||
|
|
}
|
|||
|
|
function endpointCandidatesNearPoint(b,point){
|
|||
|
|
const cell=gridCellAt(b,point);if(!cell)return[];
|
|||
|
|
const index=b.endpointIndexesByCell?.size?b.endpointIndexesByCell:rebuildEndpointIndexes(b),candidates=[];
|
|||
|
|
for(let dr=-1;dr<=1;dr++)for(let dc=-1;dc<=1;dc++)for(const entry of index.get(ckey(cell[0]+dr,cell[1]+dc))||[])candidates.push(entry);
|
|||
|
|
return candidates;
|
|||
|
|
}
|
|||
|
|
function nearestEndpointAtPoint(b,point){
|
|||
|
|
if(!point)return null;const radius=Math.min(pointerLocalRadius(b,30),CELL*.48),limit=radius*radius;
|
|||
|
|
let best=null,bestD=limit;
|
|||
|
|
for(const entry of endpointCandidatesNearPoint(b,point)){const tip=boardCellCenter(entry.cell),d=(tip[0]-point[0])**2+(tip[1]-point[1])**2;if(d<bestD){best=entry.index;bestD=d}}
|
|||
|
|
return best;
|
|||
|
|
}
|
|||
|
|
function overlappingOpenTipAtPoint(b,point,pixels=42){
|
|||
|
|
const drawing=b?.drawing,st=drawing&&metaState(b.id),active=st?.paths?.[drawing.pathIndex];if(!point||!active?.cells?.length)return null;
|
|||
|
|
const activeTip=active.cells[active.cells.length-1],radius=Math.min(pointerLocalRadius(b,pixels),CELL*.82),limit=radius*radius;let best=null,bestDistance=limit;
|
|||
|
|
const endpointIndex=b.endpointIndexesByCell?.size?b.endpointIndexesByCell:rebuildEndpointIndexes(b,st);
|
|||
|
|
for(const entry of endpointIndex.get(ckey(...activeTip))||[]){if(entry.index===drawing.pathIndex)continue;const center=boardCellCenter(entry.cell),distance=(center[0]-point[0])**2+(center[1]-point[1])**2;if(distance<=bestDistance){best={index:entry.index,side:entry.side};bestDistance=distance}}
|
|||
|
|
return best;
|
|||
|
|
}
|
|||
|
|
function boardPointForGate(b,gateIndex){const gp=gatePoint(gateObj(b.p,gateIndex));return {index:gateIndex,point:gp,cell:[b.p.g[gateIndex][0],b.p.g[gateIndex][1]]}}
|
|||
|
|
function drawingFromGate(b,pathIndex,gateIndex,pointerId=null){
|
|||
|
|
const gateCell=[b.p.g[gateIndex][0],b.p.g[gateIndex][1]],point=boardPointForGate(b,gateIndex).point;
|
|||
|
|
const drawing=drawingForPath(b,pathIndex,pointerId,point);
|
|||
|
|
drawing.originGate=gateIndex;drawing.originGateCell=gateCell;return drawing;
|
|||
|
|
}
|
|||
|
|
function updateGateSnapArming(b,drawing){
|
|||
|
|
if(!drawing||drawing.gateSnapArmed||!Number.isInteger(drawing.originGate)||!Array.isArray(drawing.originGateCell))return;
|
|||
|
|
const path=metaState(b.id).paths[drawing.pathIndex],currentCell=path?.cells?.[path.cells.length-1];
|
|||
|
|
if(currentCell&&!sameCell(currentCell,drawing.originGateCell)){drawing.gateSnapArmed=true;drawing.leftOriginGateCell=true}
|
|||
|
|
}
|
|||
|
|
function canonicalGateColorIndex(meta,gateIndex,path=null){
|
|||
|
|
const inherited=neighborColor(meta,gateIndex);
|
|||
|
|
return inherited??pathColorIndexAtGate(path,gateIndex)??chooseColor(meta,gateIndex)??0;
|
|||
|
|
}
|
|||
|
|
function gateFromCell(b,cell,point=null,exclude=null,maxPixels=null){
|
|||
|
|
if(!cell)return null;
|
|||
|
|
const reference=point||boardCellCenter(cell);
|
|||
|
|
return gateCandidateAtPoint(b,reference,{cellHint:cell,exclude,maxPixels});
|
|||
|
|
}
|
|||
|
|
function gateFromPointOrCell(b,point,cellHint=null){
|
|||
|
|
const exactCell=cellHint||cellAt(b,point);if(!exactCell)return null;
|
|||
|
|
return gateCandidateAtPoint(b,point,{cellHint:exactCell,maxPixels:0});
|
|||
|
|
}
|
|||
|
|
function gateStartCandidate(b,point,cellHint=null,directGate=null){return gateCandidateAtPoint(b,point,{cellHint,directGate,maxPixels:34})}
|
|||
|
|
function armGate(b,gateIndex){
|
|||
|
|
b.armedGate=Number.isInteger(gateIndex)?gateIndex:null;
|
|||
|
|
if(Number.isInteger(gateIndex))b.gateKnobs[gateIndex]?.classList.add('show');
|
|||
|
|
}
|
|||
|
|
function edgePanVelocity(clientX,clientY){
|
|||
|
|
const rect=getViewportRect(),margin=Math.min(DRAG_EDGE_MARGIN,rect.width*.22,rect.height*.22),axis=(value,min,max)=>value<min+margin?-(1-(value-min)/margin):value>max-margin?1-(max-value)/margin:0;
|
|||
|
|
return[axis(clientX,rect.left,rect.right)*DRAG_EDGE_MAX_SPEED,axis(clientY,rect.top,rect.bottom)*DRAG_EDGE_MAX_SPEED];
|
|||
|
|
}
|
|||
|
|
function pointerInsideBoardScreen(b,move){const rect=boardScreenRect(b);return Boolean(move&&rect.width&&rect.height&&move.clientX>=rect.left&&move.clientX<=rect.right&&move.clientY>=rect.top&&move.clientY<=rect.bottom)}
|
|||
|
|
function hidePickupHandleOverlay(){pickupHandleOverlay?.classList.remove('visible')}
|
|||
|
|
function updatePickupHandleOverlay(b,drawing,clientX,clientY){
|
|||
|
|
if(!pickupHandleOverlay||!drawing||!Number.isFinite(clientX)||!Number.isFinite(clientY))return false;
|
|||
|
|
const path=activePath(b),color=LINE_COLORS[(path?.endColorIndex??path?.startColorIndex??path?.colorIndex??0)%LINE_COLORS.length],transform=`translate3d(${clientX}px,${clientY}px,0) translate(-50%,-50%)`;
|
|||
|
|
if(pickupHandleOverlay.style.transform!==transform)pickupHandleOverlay.style.transform=transform;
|
|||
|
|
if(pickupHandleOverlay.style.getPropertyValue('--pickup-color')!==color)pickupHandleOverlay.style.setProperty('--pickup-color',color);
|
|||
|
|
pickupHandleOverlay.classList.add('visible');return true;
|
|||
|
|
}
|
|||
|
|
function usesLightweightDragOverlay(b){return(b?.p?.valid?.length||0)>=LIGHTWEIGHT_DRAG_BOARD_CELLS}
|
|||
|
|
function setLightweightDragPresentation(b,active){
|
|||
|
|
if(!b||!usesLightweightDragOverlay(b))return false;const next=Boolean(active);if(b.lightweightDragPresentation===next)return false;b.lightweightDragPresentation=next;
|
|||
|
|
for(const layer of[b.connectorLayer,b.pathLayer,b.specialLayer,b.gateLayer,b.numberLayer])if(layer)layer.style.opacity=next?'0':'';
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
function setPickupScenePresentation(activeBoard=null,active=false){
|
|||
|
|
const next=Boolean(active);
|
|||
|
|
for(const board of rendered.values())setLightweightDragPresentation(board,next&&board===activeBoard);
|
|||
|
|
}
|
|||
|
|
function recordPickupStartFrame(b,drawing){
|
|||
|
|
if(!drawing||drawing.pickupStartMeasured)return false;drawing.pickupStartMeasured=true;
|
|||
|
|
perfGauge('pickupStartFullBoardRenders',Math.max(0,(perfCounters.fullBoardRenders||0)-(drawing.fullBoardRendersAtPointerDown||0)));
|
|||
|
|
if(Number.isFinite(drawing.pointerDownAt))perfObserve('pickupStartLatency',Math.max(0,perfNow()-drawing.pointerDownAt));return true;
|
|||
|
|
}
|
|||
|
|
function cancelBoardDragFrame(b){if(!b)return;if(b.pointerMoveFrame)cancelAnimationFrame(b.pointerMoveFrame);if(b.pointerMoveDelayTimer)clearTimeout(b.pointerMoveDelayTimer);b.pointerMoveTaskController?.abort();b.pointerMoveFrame=0;b.pointerMoveDelayTimer=0;b.pointerMoveTaskController=null;b.pendingPointerMove=null;b.pointerMoveSamples=[];b.lastProcessedPointerMove=null;b.dragFrameAt=0;b.dragNextFrameAt=0;b.dragVisualActiveUntil=0;b.dragVisualX=NaN;b.dragVisualY=NaN;b.dragVisualLastFrameAt=0;b.dragVisualCommitAt=0;b.dragSvgVisualAt=0;b.dragTrailRevision=-1;b.dragInputRevision=0;b.dragVisualCommittedInputRevision=0;b.dragLogicalCommittedInputRevision=0;hidePickupHandleOverlay()}
|
|||
|
|
function scheduleBoardDragFrame(b){
|
|||
|
|
if(!b||b.pointerMoveFrame||b.pointerMoveDelayTimer||b.pointerMoveTaskController)return;
|
|||
|
|
let committed=false;
|
|||
|
|
const step=timestamp=>{
|
|||
|
|
if(committed)return;committed=true;
|
|||
|
|
if(b.dragVisualLastFrameAt&×tamp+INTERACTION_FRAME_TOLERANCE_MS<b.dragVisualLastFrameAt+DRAG_FRAME_INTERVAL){committed=false;b.pointerMoveFrame=requestAnimationFrame(step);return}
|
|||
|
|
if(b.pointerMoveFrame)cancelAnimationFrame(b.pointerMoveFrame);
|
|||
|
|
if(b.pointerMoveDelayTimer)clearTimeout(b.pointerMoveDelayTimer);
|
|||
|
|
b.pointerMoveTaskController?.abort();
|
|||
|
|
b.pointerMoveFrame=0;b.pointerMoveDelayTimer=0;b.pointerMoveTaskController=null;
|
|||
|
|
processBoardDragFrame(b,timestamp);
|
|||
|
|
};
|
|||
|
|
const arm=()=>{
|
|||
|
|
b.pointerMoveDelayTimer=0;
|
|||
|
|
b.pointerMoveFrame=requestAnimationFrame(step);
|
|||
|
|
const watchdog=()=>{perfCount('pickupDisplayWatchdogFrames');step(perfNow())};
|
|||
|
|
if(globalThis.scheduler?.postTask){const controller=new AbortController();b.pointerMoveTaskController=controller;scheduler.postTask(watchdog,{priority:'user-blocking',delay:DRAG_DISPLAY_WATCHDOG_MS,signal:controller.signal}).catch(()=>{})}
|
|||
|
|
else b.pointerMoveDelayTimer=setTimeout(watchdog,DRAG_DISPLAY_WATCHDOG_MS);
|
|||
|
|
};
|
|||
|
|
arm();
|
|||
|
|
}
|
|||
|
|
function pointerEventSamples(event){
|
|||
|
|
let sample=event;if(event?.pointerType!=='mouse'&&typeof event?.getCoalescedEvents==='function'){const coalesced=event.getCoalescedEvents();if(coalesced?.length)sample=coalesced[coalesced.length-1]}
|
|||
|
|
return sample&&Number.isFinite(sample.clientX)&&Number.isFinite(sample.clientY)?{pointerId:event.pointerId,clientX:sample.clientX,clientY:sample.clientY,inputAt:Number(sample.timeStamp)||Number(event.timeStamp)||perfNow()}:null;
|
|||
|
|
}
|
|||
|
|
function trimBoardPointerSamples(samples){
|
|||
|
|
while(samples.length>DRAG_MAX_POINTER_SAMPLES){
|
|||
|
|
let removeIndex=1,leastTurn=Infinity;
|
|||
|
|
for(let index=1;index<samples.length-1;index++){
|
|||
|
|
const a=samples[index-1],b=samples[index],c=samples[index+1],
|
|||
|
|
turn=Math.abs((b.clientX-a.clientX)*(c.clientY-b.clientY)-(b.clientY-a.clientY)*(c.clientX-b.clientX));
|
|||
|
|
if(turn<leastTurn){leastTurn=turn;removeIndex=index}
|
|||
|
|
}
|
|||
|
|
samples.splice(removeIndex,1);
|
|||
|
|
}
|
|||
|
|
return samples;
|
|||
|
|
}
|
|||
|
|
function setBoardPointerSample(b,sample){
|
|||
|
|
if(!b||!sample)return null;const samples=b.pointerMoveSamples||(b.pointerMoveSamples=[]);samples.push(sample);trimBoardPointerSamples(samples);
|
|||
|
|
b.pendingPointerMove=sample;b.dragInputRevision=(b.dragInputRevision||0)+1;b.dragVisualActiveUntil=perfNow()+40;return sample;
|
|||
|
|
}
|
|||
|
|
function appendBoardPointerSamples(b,event){return setBoardPointerSample(b,pointerEventSamples(event))}
|
|||
|
|
function commitBoardDragFromInputDeadline(b){
|
|||
|
|
const now=perfNow();if(!b?.drawing||b.dragVisualLastFrameAt&&now+INTERACTION_FRAME_TOLERANCE_MS<b.dragVisualLastFrameAt+DRAG_FRAME_INTERVAL)return false;
|
|||
|
|
if(b.pointerMoveFrame)cancelAnimationFrame(b.pointerMoveFrame);if(b.pointerMoveDelayTimer)clearTimeout(b.pointerMoveDelayTimer);b.pointerMoveTaskController?.abort();
|
|||
|
|
b.pointerMoveFrame=0;b.pointerMoveDelayTimer=0;b.pointerMoveTaskController=null;processBoardDragFrame(b,now);perfCount('pickupInputDeadlineFrames');return true;
|
|||
|
|
}
|
|||
|
|
function processBoardDragFrame(b,timestamp){
|
|||
|
|
const started=perfStart();perfCount('dragFrames');markVisualFrame(timestamp);b.pointerMoveFrame=0;const move=b.pendingPointerMove,drawing=b.drawing;b.pendingPointerMove=null;if(!move||!drawing||drawing.pointerId!==move.pointerId){cancelBoardDragFrame(b);perfEnd('processBoardDragFrame',started);return}
|
|||
|
|
const logicalSlotDue=!b.dragNextFrameAt||timestamp+2>=b.dragNextFrameAt,
|
|||
|
|
freshVisualInput=b.dragVisualCommittedInputRevision!==b.dragInputRevision,
|
|||
|
|
freshLogicalInput=b.dragLogicalCommittedInputRevision!==b.dragInputRevision;
|
|||
|
|
if(freshVisualInput){recordInteractionCommit('pickupVisual',timestamp,move.inputAt,true);b.dragVisualCommitAt=timestamp;b.dragVisualCommittedInputRevision=b.dragInputRevision}
|
|||
|
|
else if(timestamp<=b.dragVisualActiveUntil)recordInteractionCommit('pickupVisual',timestamp,move.inputAt,false);
|
|||
|
|
b.lastProcessedPointerMove=move;
|
|||
|
|
if(logicalSlotDue){let nextFrame=b.dragNextFrameAt||timestamp;do nextFrame+=DRAG_FRAME_INTERVAL;while(nextFrame<=timestamp+2);b.dragNextFrameAt=nextFrame}
|
|||
|
|
const visualElapsed=b.dragVisualLastFrameAt?Math.min(50,Math.max(1,timestamp-b.dragVisualLastFrameAt)):16.67,visualBlend=1-Math.exp(-visualElapsed/8);
|
|||
|
|
if(!Number.isFinite(b.dragVisualX)){b.dragVisualX=move.clientX;b.dragVisualY=move.clientY}
|
|||
|
|
else{b.dragVisualX+=(move.clientX-b.dragVisualX)*visualBlend;b.dragVisualY+=(move.clientY-b.dragVisualY)*visualBlend}
|
|||
|
|
b.dragVisualLastFrameAt=timestamp;
|
|||
|
|
touchBoardClaim(b.id);const velocity=edgePanVelocity(move.clientX,move.clientY),dt=Math.min(32,Math.max(8,timestamp-(b.dragFrameAt||timestamp-16)));b.dragFrameAt=timestamp;
|
|||
|
|
if(velocity[0]||velocity[1]){cam.x-=velocity[0]*dt;cam.y-=velocity[1]*dt;applyCamera(true)}
|
|||
|
|
const logicalDue=logicalSlotDue&&(freshLogicalInput||velocity[0]||velocity[1]||b.drawing?.catchupPending||b.pointerMoveSamples?.length);
|
|||
|
|
if(logicalDue){
|
|||
|
|
const modelStarted=perfStart();let processed=0,logicalMove=move;
|
|||
|
|
do{
|
|||
|
|
logicalMove=freshLogicalInput&&b.pointerMoveSamples?.length?b.pointerMoveSamples.shift():move;processed++;
|
|||
|
|
const point=pointerInsideBoardScreen(b,logicalMove)?eventToSvg(b,logicalMove):null,traceCell=point?cellAt(b,point):null;
|
|||
|
|
if(traceCell){const trace=b.logicalPointerCellTrace||(b.logicalPointerCellTrace=[]);trace.push(traceCell);if(trace.length>48)trace.shift()}
|
|||
|
|
if(point&&b.drawing?.pointerId===logicalMove.pointerId)extendPointerTo(b,point,true);
|
|||
|
|
}while(freshLogicalInput&&b.pointerMoveSamples?.length&&processed<DRAG_MAX_LOGICAL_SAMPLES_PER_FRAME&&perfNow()-modelStarted<DRAG_MODEL_BUDGET_MS)
|
|||
|
|
recordInteractionCommit('pickupLogical',timestamp,logicalMove.inputAt,freshLogicalInput);
|
|||
|
|
if(freshLogicalInput&&!b.pointerMoveSamples?.length)b.dragLogicalCommittedInputRevision=b.dragInputRevision;
|
|||
|
|
perfEnd('pickupModelWork',modelStarted);
|
|||
|
|
}
|
|||
|
|
const visualStarted=perfStart();
|
|||
|
|
const visualMove=b.dragVisualMove||(b.dragVisualMove={clientX:0,clientY:0});visualMove.clientX=b.dragVisualX;visualMove.clientY=b.dragVisualY;
|
|||
|
|
updatePickupHandleOverlay(b,b.drawing,b.dragVisualX,b.dragVisualY);
|
|||
|
|
const visualPoint=eventToSvg(b,visualMove);if(visualPoint&&b.drawing?.pointerId===move.pointerId)updateDrawingHandlePosition(b,b.drawing,visualPoint);
|
|||
|
|
const logicalRevision=b.drawing?.logicalRevision||0,svgVisualDue=!b.dragCache||b.dragCache.logicalRevision!==logicalRevision;
|
|||
|
|
if(usesLightweightDragOverlay(b)){recordPickupStartFrame(b,b.drawing);dirtyBoards.delete(b)}
|
|||
|
|
else if(svgVisualDue&&b.drawing?.pointerId===move.pointerId)renderDragFrame(b);
|
|||
|
|
perfEnd('pickupVisualWork',visualStarted);
|
|||
|
|
if((perfNow()<b.dragVisualActiveUntil||velocity[0]||velocity[1]||b.drawing?.catchupPending||b.pointerMoveSamples?.length)&&b.drawing?.pointerId===move.pointerId){b.pendingPointerMove=move;scheduleBoardDragFrame(b)}
|
|||
|
|
const workDuration=perfEnd('processBoardDragFrame',started);observeInteractionFrame(timestamp,workDuration);
|
|||
|
|
}
|
|||
|
|
function queueBoardPointerMove(b,event){
|
|||
|
|
appendBoardPointerSamples(b,event);
|
|||
|
|
if(!commitBoardDragFromInputDeadline(b))scheduleBoardDragFrame(b);
|
|||
|
|
}
|
|||
|
|
function flushBoardPointerMove(b,event){
|
|||
|
|
if(b.pointerMoveFrame)cancelAnimationFrame(b.pointerMoveFrame);if(b.pointerMoveDelayTimer)clearTimeout(b.pointerMoveDelayTimer);b.pointerMoveTaskController?.abort();b.pointerMoveFrame=0;b.pointerMoveDelayTimer=0;b.pointerMoveTaskController=null;
|
|||
|
|
if(event)appendBoardPointerSamples(b,event);const moves=b.pointerMoveSamples?.splice(0)||[];
|
|||
|
|
b.pendingPointerMove=null;b.pointerMoveSamples=[];b.lastProcessedPointerMove=null;b.dragFrameAt=0;b.dragNextFrameAt=0;b.dragVisualActiveUntil=0;b.dragVisualX=NaN;b.dragVisualY=NaN;b.dragVisualLastFrameAt=0;b.dragVisualCommitAt=0;b.dragSvgVisualAt=0;b.dragTrailRevision=-1;b.dragInputRevision=0;b.dragVisualCommittedInputRevision=0;b.dragLogicalCommittedInputRevision=0;hidePickupHandleOverlay();
|
|||
|
|
b.lastFlushPointerCells=[];
|
|||
|
|
for(let index=0;index<moves.length;index++){const move=moves[index],point=pointerInsideBoardScreen(b,move)?eventToSvg(b,move):null,traceCell=point?cellAt(b,point):null;if(traceCell){const trace=b.logicalPointerCellTrace||(b.logicalPointerCellTrace=[]);b.lastFlushPointerCells.push(traceCell);trace.push(traceCell);if(trace.length>48)trace.shift()}if(point&&b.drawing?.pointerId===move.pointerId)extendPointerTo(b,point,true,index===moves.length-1?Infinity:DRAG_MAX_CATCHUP_CELLS)}
|
|||
|
|
if(b.drawing&&!usesLightweightDragOverlay(b))renderDragFrame(b);
|
|||
|
|
}
|
|||
|
|
function reconnectOpenGateOnRelease(b){
|
|||
|
|
const drawing=b?.drawing,path=activePath(b),gateIndex=path?.openGate;
|
|||
|
|
if(!drawing||!path||!Number.isInteger(gateIndex)||drawing.leftOriginGateCell)return false;
|
|||
|
|
const gateCell=[b.p.g[gateIndex][0],b.p.g[gateIndex][1]],tip=path.cells[path.cells.length-1];
|
|||
|
|
if(!sameCell(gateCell,tip))return false;
|
|||
|
|
return finalizeAtGate(b,gateIndex);
|
|||
|
|
}
|
|||
|
|
function discardUnmovedCreatedPath(b,drawing=b?.drawing){
|
|||
|
|
if(!drawing?.createdPath)return false;
|
|||
|
|
const st=metaState(b.id),path=st.paths[drawing.pathIndex];if(!path||path.endGate!=null||path.cells.length!==1)return false;
|
|||
|
|
return applyBoardCommand(b,()=>{st.paths.splice(drawing.pathIndex,1);b.drawing=null;b.armedGate=null},{persist:true,deferSettlement:true});
|
|||
|
|
}
|
|||
|
|
function schedulePendingClaimPreview(b){
|
|||
|
|
if(!b?.pendingClaimPointer||b.pendingClaimFrame)return;
|
|||
|
|
b.pendingClaimFrame=requestAnimationFrame(timestamp=>{
|
|||
|
|
b.pendingClaimFrame=0;const pending=b.pendingClaimPointer;if(!pending)return;const point=eventToSvg(b,pending);if(!point)return;
|
|||
|
|
pending.preview.setAttribute('transform',`translate(${point[0]} ${point[1]})`);recordInteractionCommit('pickupPreview',timestamp,pending.inputAt);markVisualFrame(timestamp);perfCount('pickupPreviewFrames');
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
function beginPendingClaimPointer(b,event,targetInfo){
|
|||
|
|
const started=perfStart();
|
|||
|
|
if(b.pendingClaimPointer)clearPendingClaimPointer(b,b.pendingClaimPointer.pointerId);
|
|||
|
|
const preview=svgEl('g',{class:'claim-pending-preview'}),halo=svgEl('circle',{r:9,class:'claim-pending-halo'}),knob=svgEl('circle',{r:5.5,class:'claim-pending-knob'});
|
|||
|
|
preview.append(halo,knob);b.claimPreviewLayer.replaceChildren(preview);
|
|||
|
|
const initialSample=pointerEventSamples(event);
|
|||
|
|
b.pendingClaimPointer={pointerId:event.pointerId,clientX:event.clientX,clientY:event.clientY,inputAt:Number(event.timeStamp)||perfNow(),samples:initialSample?[initialSample]:[],startedAt:perfNow(),fullBoardRendersAtDown:perfCounters.fullBoardRenders||0,preview,...targetInfo};
|
|||
|
|
try{b.svg.setPointerCapture(event.pointerId)}catch(_){}refreshInteractionState();schedulePendingClaimPreview(b);perfEnd('pickupPointerDownPreview',started);return b.pendingClaimPointer;
|
|||
|
|
}
|
|||
|
|
function updatePendingClaimPointer(b,event){
|
|||
|
|
const pending=b?.pendingClaimPointer;if(!pending||pending.pointerId!==event.pointerId)return false;
|
|||
|
|
const sample=pointerEventSamples(event);if(sample){pending.clientX=sample.clientX;pending.clientY=sample.clientY;pending.inputAt=sample.inputAt;(pending.samples||(pending.samples=[])).push(sample);trimBoardPointerSamples(pending.samples);schedulePendingClaimPreview(b)}return true;
|
|||
|
|
}
|
|||
|
|
function clearPendingClaimPointer(b,pointerId,{release=true}={}){
|
|||
|
|
const pending=b?.pendingClaimPointer;if(!pending||pointerId!=null&&pending.pointerId!==pointerId)return null;
|
|||
|
|
if(b.pendingClaimFrame)cancelAnimationFrame(b.pendingClaimFrame);b.pendingClaimFrame=0;b.pendingClaimPointer=null;b.claimPreviewLayer.replaceChildren();
|
|||
|
|
if(release)try{if(b.svg.hasPointerCapture?.(pending.pointerId))b.svg.releasePointerCapture(pending.pointerId)}catch(_){}
|
|||
|
|
refreshInteractionState();return pending;
|
|||
|
|
}
|
|||
|
|
function activateBoardPointerDrag(b,pending,point){
|
|||
|
|
const started=perfStart();
|
|||
|
|
const drawing=b?.drawing;if(!drawing||!pending||!point){perfEnd('pickupPointerDownCommit',started);return false}
|
|||
|
|
cancelBoardDragFrame(b);
|
|||
|
|
b.logicalPointerCellTrace=[];b.lastFlushPointerCells=[];
|
|||
|
|
drawing.lastPoint=point;updateDrawingPointerState(b,drawing,point);drawing.pointerDownAt=pending.startedAt;drawing.fullBoardRendersAtPointerDown=pending.fullBoardRendersAtDown;
|
|||
|
|
setPickupScenePresentation(b,true);
|
|||
|
|
safeCapture(b.svg,pending.pointerId);
|
|||
|
|
const samples=pending.samples?.length?pending.samples:[{pointerId:pending.pointerId,clientX:pending.clientX,clientY:pending.clientY,inputAt:pending.inputAt}];
|
|||
|
|
for(const sample of samples)setBoardPointerSample(b,sample);
|
|||
|
|
scheduleBoardDragFrame(b);perfEnd('pickupPointerDownCommit',started);return true;
|
|||
|
|
}
|
|||
|
|
function bindBoard(b){
|
|||
|
|
b.card.addEventListener('focus',()=>selectBoard(b));
|
|||
|
|
b.svg.addEventListener('pointerdown',async e=>{
|
|||
|
|
if(e.button!==0||b.joiningPaths)return;
|
|||
|
|
e.preventDefault();
|
|||
|
|
e.stopPropagation();
|
|||
|
|
selectBoard(b,{paint:false});
|
|||
|
|
let st=metaState(b.id);
|
|||
|
|
if(st.solved)return;
|
|||
|
|
const endpointTarget=e.target.closest?.('.endpoint-hit'),directGate=Number(e.target.closest?.('.gate-hit')?.dataset.gate),
|
|||
|
|
targetEndpoint=Number(endpointTarget?.dataset.pathIndex),targetEndpointSide=endpointTarget?.dataset.endpointSide||'end',
|
|||
|
|
pending=beginPendingClaimPointer(b,e,{directGate,targetEndpoint,targetEndpointSide});
|
|||
|
|
const claimApproved=await ensureBoardClaimForInput(b);
|
|||
|
|
if(!claimApproved||b.pendingClaimPointer!==pending||!realtimeHeldPointers.has(e.pointerId)){clearPendingClaimPointer(b,e.pointerId);return}
|
|||
|
|
clearPendingClaimPointer(b,e.pointerId,{release:false});
|
|||
|
|
if(!b.card.isConnected||(st=metaState(b.id)).solved){safeRelease(b.svg,e.pointerId);return}touchBoardClaim(b.id,true);
|
|||
|
|
const point=eventToSvg(b,pending);
|
|||
|
|
if(Number.isInteger(directGate)&&b.p.g[directGate]){
|
|||
|
|
const directCell=[b.p.g[directGate][0],b.p.g[directGate][1]],occupiedPath=occupiedMap(st).get(ckey(...directCell)),gatePath=st.paths.findIndex(path=>pathUsesGate(path,directGate));
|
|||
|
|
if(gatePath>=0||occupiedPath==null){armGate(b,directGate);startGate(b,directGate,e.pointerId);if(!activateBoardPointerDrag(b,pending,point)){b.armedGate=null;renderBoard(b)}return}
|
|||
|
|
}
|
|||
|
|
if(Number.isInteger(targetEndpoint)&&st.paths[targetEndpoint]?.endGate==null){
|
|||
|
|
if(st.paths[targetEndpoint].detachedStart)orientDetachedEndpoint(b,targetEndpoint,targetEndpointSide,e.pointerId,point);
|
|||
|
|
else{b.armedGate=null;b.drawing=drawingForPath(b,targetEndpoint,e.pointerId,point)}
|
|||
|
|
activateBoardPointerDrag(b,pending,point);return;
|
|||
|
|
}
|
|||
|
|
const nearestEndpoint=nearestEndpointAtPoint(b,point);
|
|||
|
|
if(Number.isInteger(nearestEndpoint)&&st.paths[nearestEndpoint]?.endGate==null){
|
|||
|
|
b.armedGate=null;b.drawing=drawingForPath(b,nearestEndpoint,e.pointerId,point);
|
|||
|
|
activateBoardPointerDrag(b,pending,point);return;
|
|||
|
|
}
|
|||
|
|
const hintCell=cellAt(b,point),
|
|||
|
|
targetGate=gateStartCandidate(b,point,hintCell,directGate),occupiedPath=hintCell?occupiedMap(st).get(ckey(...hintCell)):null,
|
|||
|
|
gatePath=Number.isInteger(targetGate)?st.paths.findIndex(path=>pathUsesGate(path,targetGate)):-1;
|
|||
|
|
if(Number.isInteger(targetGate)&&(gatePath>=0||occupiedPath==null)){
|
|||
|
|
armGate(b,targetGate);startGate(b,targetGate,e.pointerId);
|
|||
|
|
if(!activateBoardPointerDrag(b,pending,point)){b.armedGate=null;renderBoard(b)}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if(Number.isInteger(occupiedPath)){
|
|||
|
|
if(reopenPathAtCell(b,occupiedPath,hintCell,e.pointerId)){playSound('grab');activateBoardPointerDrag(b,pending,point);return}
|
|||
|
|
}
|
|||
|
|
if(Number.isInteger(targetGate)){
|
|||
|
|
armGate(b,targetGate);startGate(b,targetGate,e.pointerId);
|
|||
|
|
if(!activateBoardPointerDrag(b,pending,point)){b.armedGate=null;renderBoard(b)}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const cell=cellAt(b,point);
|
|||
|
|
if(!cell)return;
|
|||
|
|
const pi=occupiedMap(st).get(ckey(...cell));
|
|||
|
|
if(pi!=null){
|
|||
|
|
if(reopenPathAtCell(b,pi,cell,e.pointerId)){
|
|||
|
|
activateBoardPointerDrag(b,pending,point);return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
});
|
|||
|
|
b.svg.addEventListener('pointermove',e=>{
|
|||
|
|
if(updatePendingClaimPointer(b,e)){e.preventDefault();return}
|
|||
|
|
if(!b.drawing||b.drawing.pointerId!==e.pointerId)return;
|
|||
|
|
e.preventDefault();queueBoardPointerMove(b,e);
|
|||
|
|
});
|
|||
|
|
const finishPointer=(e,flush=true)=>{
|
|||
|
|
const started=perfStart();try{
|
|||
|
|
if(b.pendingClaimPointer?.pointerId===e.pointerId){clearPendingClaimPointer(b,e.pointerId);return}
|
|||
|
|
if(!b.drawing||b.drawing.pointerId!==e.pointerId)return;
|
|||
|
|
const release=()=>{clearDragRender(b);safeRelease(b.svg,e.pointerId);queueMicrotask(()=>updateCustomCursorFromPointer(e))};
|
|||
|
|
if(flush)flushBoardPointerMove(b,e);else cancelBoardDragFrame(b);
|
|||
|
|
if(!b.drawing||b.drawing.pointerId!==e.pointerId){release();return}
|
|||
|
|
if(flush&&reconnectOpenGateOnRelease(b)){release();return}
|
|||
|
|
if(discardUnmovedCreatedPath(b)){release();return}
|
|||
|
|
const finishedPathIndex=b.drawing.pathIndex;b.drawing=null;b.armedGate=null;refreshInteractionState();
|
|||
|
|
const removedWarpKnobPaths=sanitizeStateForPuzzle(b.meta,{quiet:true});
|
|||
|
|
changed(b.id);if(removedWarpKnobPaths)toast('両端がワープセルにある未接続の線を削除しました。');
|
|||
|
|
scheduleBoardCommandSettlement(b,{paint:true,invalidate:true,pathIndex:finishedPathIndex});
|
|||
|
|
release();
|
|||
|
|
}finally{perfEnd('pickupPointerFinish',started)}
|
|||
|
|
};
|
|||
|
|
b.svg.addEventListener('pointerup',e=>finishPointer(e,true));
|
|||
|
|
b.svg.addEventListener('pointercancel',e=>finishPointer(e,false));
|
|||
|
|
b.svg.addEventListener('lostpointercapture',e=>{if(b.pendingClaimPointer?.pointerId===e.pointerId)clearPendingClaimPointer(b,e.pointerId,{release:false});else if(b.drawing?.pointerId===e.pointerId)finishPointer(e,false);else refreshInteractionState()});
|
|||
|
|
b.svg.addEventListener('contextmenu',e=>e.preventDefault());
|
|||
|
|
b.svg.addEventListener('focusin',()=>selectBoard(b));
|
|||
|
|
b.svg.addEventListener('keydown',async e=>{
|
|||
|
|
if(document.querySelector('#modal').classList.contains('show')||document.querySelector('#storeModal').classList.contains('show')||document.querySelector('#inventoryModal').classList.contains('show')||document.querySelector('#timeAttackModal').classList.contains('show'))return;
|
|||
|
|
const st=metaState(b.id);if(st.solved)return;
|
|||
|
|
const gateTarget=e.target.closest?.('.gate-hit'),endpointTarget=e.target.closest?.('.endpoint-hit'),claimKey=e.key==='Enter'||e.key===' '||e.key==='Backspace'||['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.key);
|
|||
|
|
if(claimKey&&!await ensureBoardClaimForInput(b))return;if(claimKey)touchBoardClaim(b.id);
|
|||
|
|
if((e.key==='Enter'||e.key===' ')&&gateTarget){
|
|||
|
|
e.preventDefault();selectBoard(b);startGate(b,Number(gateTarget.dataset.gate),null);b.svg.focus({preventScroll:true});return;
|
|||
|
|
}
|
|||
|
|
if((e.key==='Enter'||e.key===' ')&&endpointTarget){
|
|||
|
|
e.preventDefault();selectBoard(b);const pi=Number(endpointTarget.dataset.pathIndex),side=endpointTarget.dataset.endpointSide||'end';
|
|||
|
|
if(st.paths[pi]?.endGate==null){if(st.paths[pi].detachedStart)orientDetachedEndpoint(b,pi,side,null,null);else b.drawing=drawingForPath(b,pi,null,null);b.drawing.keyboardActive=true;renderBoard(b);b.svg.focus({preventScroll:true})}return;
|
|||
|
|
}
|
|||
|
|
const moves={ArrowUp:[-1,0],ArrowDown:[1,0],ArrowLeft:[0,-1],ArrowRight:[0,1]};
|
|||
|
|
if(moves[e.key]&&activePath(b)){
|
|||
|
|
e.preventDefault();b.drawing.keyboardActive=true;const path=activePath(b),last=path.cells[path.cells.length-1],move=moves[e.key];
|
|||
|
|
extendOne(b,[last[0]+move[0],last[1]+move[1]]);b.svg.focus({preventScroll:true});return;
|
|||
|
|
}
|
|||
|
|
if(e.key==='Backspace'&&activePath(b)){
|
|||
|
|
e.preventDefault();b.drawing.keyboardActive=true;const path=activePath(b);
|
|||
|
|
if(path.cells.length>1)extendOne(b,path.cells[path.cells.length-2]);
|
|||
|
|
else toast('\u59cb\u70b9\u306f\u524a\u9664\u3067\u304d\u307e\u305b\u3093\u3002');
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
let renderOriginX=0,renderOriginY=0;
|
|||
|
|
let viewportRectCache=null,viewportResizeFrame=0;
|
|||
|
|
function getViewportRect(force=false){if(force||!viewportRectCache)viewportRectCache=viewport.getBoundingClientRect();return viewportRectCache}
|
|||
|
|
function getMinimapRect(force=false){if(force||!minimapRectCache)minimapRectCache=minimapCanvas.getBoundingClientRect();return minimapRectCache}
|
|||
|
|
function boardScreenRect(board){
|
|||
|
|
const viewportRect=getViewportRect(),scale=cam.scale,left=viewportRect.left+cam.x+((board.meta.x-renderOriginX)*UNIT-PAD)*scale,top=viewportRect.top+cam.y+((board.meta.y-renderOriginY)*UNIT-PAD)*scale;
|
|||
|
|
return{left,top,right:left+board.w*scale,bottom:top+board.h*scale,width:board.w*scale,height:board.h*scale};
|
|||
|
|
}
|
|||
|
|
function boardWorldBounds(meta){const p=puzzleOf(meta);return{x:(meta.x-renderOriginX)*UNIT-PAD,y:(meta.y-renderOriginY)*UNIT-PAD,w:p.bounds.w*CELL+PAD*2,h:p.bounds.h*CELL+PAD*2}}
|
|||
|
|
function viewportChunkBounds(screenMargin=0){
|
|||
|
|
const r=getViewportRect(),margin=screenMargin/Math.max(MIN_CAMERA_SCALE,cam.scale),left=-cam.x/cam.scale-margin,top=-cam.y/cam.scale-margin,right=-cam.x/cam.scale+r.width/cam.scale+margin,bottom=-cam.y/cam.scale+r.height/cam.scale+margin;
|
|||
|
|
return{minX:renderOriginX+Math.floor(left/UNIT)-1,maxX:renderOriginX+Math.ceil(right/UNIT)+1,minY:renderOriginY+Math.floor(top/UNIT)-1,maxY:renderOriginY+Math.ceil(bottom/UNIT)+1};
|
|||
|
|
}
|
|||
|
|
function visibleMetaIdsForBounds(bounds){const ids=new Set();for(let y=bounds.minY;y<=bounds.maxY;y++)for(let x=bounds.minX;x<=bounds.maxX;x++){const id=occupancy.get(key2(x,y));if(id)ids.add(id)}return ids}
|
|||
|
|
const pendingVisibleHydrations=new Set();
|
|||
|
|
function visibleMetaIds(){
|
|||
|
|
const ids=visibleMetaIdsForBounds(viewportChunkBounds(24));
|
|||
|
|
for(const[id,b]of rendered)if(b.drawing?.pointerId!=null)ids.add(id);return ids;
|
|||
|
|
}
|
|||
|
|
function scheduleWorldOverview(markDirty=false){
|
|||
|
|
if(markDirty)overviewDirty=true;
|
|||
|
|
if(document.body.classList.contains('is-interacting'))return;
|
|||
|
|
if(!overviewCanvas||!inWorldOverview()||!overviewDirty||overviewFrame||overviewDelayTimer)return;
|
|||
|
|
const step=timestamp=>{
|
|||
|
|
if(!overviewFrame)return;
|
|||
|
|
if(timestamp-overviewLastDraw<AUXILIARY_FRAME_INTERVAL-1){overviewFrame=requestAnimationFrame(step);return}
|
|||
|
|
overviewFrame=0;overviewLastDraw=timestamp;markVisualFrame(timestamp);
|
|||
|
|
const rebuild=()=>{overviewDelayTimer=0;if(document.body.classList.contains('is-interacting'))return;drawWorldOverview()};
|
|||
|
|
overviewDelayTimer=typeof requestIdleCallback==='function'?requestIdleCallback(rebuild,{timeout:180}):setTimeout(rebuild,0);
|
|||
|
|
};
|
|||
|
|
overviewFrame=requestAnimationFrame(step);
|
|||
|
|
}
|
|||
|
|
function positionCachedWorldOverview(){
|
|||
|
|
if(!overviewCanvas||!overviewCache||!inWorldOverview())return false;
|
|||
|
|
const rect=getViewportRect(),width=Math.max(1,Math.round(rect.width)),height=Math.max(1,Math.round(rect.height)),[centerX,centerY]=cameraCenterInChunks(),
|
|||
|
|
left=width/2-overviewCache.baseWidth/2-(centerX-overviewCache.anchorX)*overviewCache.unit,
|
|||
|
|
top=height/2-overviewCache.baseHeight/2-(centerY-overviewCache.anchorY)*overviewCache.unit;
|
|||
|
|
overviewCanvas.hidden=false;overviewCanvas.style.transform=`translate3d(${left.toFixed(2)}px,${top.toFixed(2)}px,0)`;
|
|||
|
|
perfCount('overviewTransformUpdates');return true;
|
|||
|
|
}
|
|||
|
|
function rebuildWorldOverviewCache(width,height,dpr,centerX,centerY){
|
|||
|
|
if(globalThis.document?.body?.classList?.contains?.('is-interacting'))perfCount('overviewBuildsDuringInteraction');
|
|||
|
|
const started=perfStart(),unit=UNIT*cam.scale,overscan=Math.max(96,Math.min(OVERVIEW_CACHE_OVERSCAN_PX,Math.max(width,height)*.18)),baseWidth=width+overscan*2,baseHeight=height+overscan*2,
|
|||
|
|
pixelWidth=Math.max(1,Math.round(baseWidth*dpr)),pixelHeight=Math.max(1,Math.round(baseHeight*dpr));
|
|||
|
|
if(overviewBase.width!==pixelWidth||overviewBase.height!==pixelHeight){overviewBase.width=pixelWidth;overviewBase.height=pixelHeight}
|
|||
|
|
const context=overviewBase.getContext('2d',{alpha:false});context.setTransform(dpr,0,0,dpr,0,0);context.fillStyle='#101315';context.fillRect(0,0,baseWidth,baseHeight);
|
|||
|
|
const halfX=baseWidth/(2*unit),halfY=baseHeight/(2*unit),bounds={minX:Math.floor(centerX-halfX)-1,maxX:Math.ceil(centerX+halfX)+1,minY:Math.floor(centerY-halfY)-1,maxY:Math.ceil(centerY+halfY)+1},ids=visibleMetaIdsForBounds(bounds),visibleMetas=[...ids].map(id=>data.metas[id]).filter(Boolean),
|
|||
|
|
mapX=value=>baseWidth/2+(value-centerX)*unit,mapY=value=>baseHeight/2+(value-centerY)*unit;
|
|||
|
|
drawMapBoardCells(context,visibleMetas,mapX,mapY,unit,{showLevels:true});const longSegments=drawMapLongLines(context,visibleMetas,ids,mapX,mapY,Math.max(1,cam.scale*1.5));
|
|||
|
|
overviewCache={revision:minimapWorldRevision,width,height,dpr,scale:cam.scale,anchorX:centerX,anchorY:centerY,unit,overscan,baseWidth,baseHeight,longSegments,boardCount:ids.size};
|
|||
|
|
perfCount('overviewCacheBuilds');perfGauge('overviewBoards',ids.size);perfGauge('overviewPaths',longSegments);perfEnd('rebuildWorldOverviewCache',started);
|
|||
|
|
}
|
|||
|
|
function drawWorldOverview(){
|
|||
|
|
const started=perfStart();if(!overviewCanvas||!inWorldOverview())return;
|
|||
|
|
const rect=getViewportRect(),width=Math.max(1,Math.round(rect.width)),height=Math.max(1,Math.round(rect.height)),dpr=1,[centerX,centerY]=cameraCenterInChunks();
|
|||
|
|
const stale=!overviewCache||overviewCache.revision!==minimapWorldRevision||overviewCache.width!==width||overviewCache.height!==height||overviewCache.dpr!==dpr||Math.abs(overviewCache.scale-cam.scale)>1e-6||
|
|||
|
|
Math.abs(centerX-overviewCache.anchorX)*overviewCache.unit>overviewCache.overscan*.82||Math.abs(centerY-overviewCache.anchorY)*overviewCache.unit>overviewCache.overscan*.82;
|
|||
|
|
if(stale){
|
|||
|
|
rebuildWorldOverviewCache(width,height,dpr,centerX,centerY);
|
|||
|
|
const bitmapWidth=Math.max(1,Math.round(overviewCache.baseWidth*dpr)),bitmapHeight=Math.max(1,Math.round(overviewCache.baseHeight*dpr));
|
|||
|
|
if(overviewCanvas.width!==bitmapWidth||overviewCanvas.height!==bitmapHeight){overviewCanvas.width=bitmapWidth;overviewCanvas.height=bitmapHeight}
|
|||
|
|
const context=overviewCanvas.getContext('2d',{alpha:false});context.setTransform(1,0,0,1,0,0);context.drawImage(overviewBase,0,0,bitmapWidth,bitmapHeight);
|
|||
|
|
overviewCanvas.style.width=`${overviewCache.baseWidth}px`;overviewCanvas.style.height=`${overviewCache.baseHeight}px`;perfCount('overviewBitmapCopies');
|
|||
|
|
}
|
|||
|
|
overviewCanvas.hidden=false;overviewDirty=false;
|
|||
|
|
// Move the oversized cached bitmap as one compositor layer. Previous builds
|
|||
|
|
// copied the entire viewport canvas on every pan frame, which dominated low-
|
|||
|
|
// end devices even though the map geometry itself was cached.
|
|||
|
|
positionCachedWorldOverview();
|
|||
|
|
perfGauge('overviewBoards',overviewCache.boardCount);perfGauge('overviewPaths',overviewCache.longSegments);perfGauge('overviewShops',0);perfEnd('drawWorldOverview',started);
|
|||
|
|
}
|
|||
|
|
async function hydrateVisibleMetas(){
|
|||
|
|
if(inWorldOverview())return 0;const queue=[];
|
|||
|
|
for(const id of visibleMetaIds()){const meta=data.metas[id];if(!meta||meta.puzzle||pendingVisibleHydrations.has(id))continue;pendingVisibleHydrations.add(id);queue.push(meta)}
|
|||
|
|
let cursor=0,completed=0;async function worker(){while(cursor<queue.length){const meta=queue[cursor++];try{await hydrateMeta(meta);completed++}catch(error){data.quarantine[meta.id]={failedAt:trustedNow(),message:String(error?.message||error),retries:(data.quarantine[meta.id]?.retries||0)+1}}finally{pendingVisibleHydrations.delete(meta.id)}}}
|
|||
|
|
await Promise.all(Array.from({length:Math.min(HYDRATE_CONCURRENCY,queue.length)},worker));return completed;
|
|||
|
|
}
|
|||
|
|
let lodFrame=0;
|
|||
|
|
let lodIdleHandle=0;
|
|||
|
|
function scheduleLodPass(){if(lodFrame||lodIdleHandle)return;const run=()=>{lodFrame=0;lodIdleHandle=0;ensureBoards()},interacting=globalThis.document?.body?.classList?.contains?.('is-interacting')===true;if(interacting&&typeof requestIdleCallback==='function')lodIdleHandle=requestIdleCallback(run,{timeout:500});else if(interacting)lodIdleHandle=setTimeout(run,180);else lodFrame=requestAnimationFrame(run)}
|
|||
|
|
function desiredInteractiveBoardIds(ids){
|
|||
|
|
const desired=new Set();
|
|||
|
|
for(const id of ids)if(data.metas[id]?.puzzle)desired.add(id);
|
|||
|
|
return desired;
|
|||
|
|
}
|
|||
|
|
function ensureBoards(){
|
|||
|
|
const started=perfStart();perfCount('ensureBoardsRuns');
|
|||
|
|
if(globalThis.document?.body?.classList?.contains?.('is-interacting')===true){perfCount('lodPassesDeferredDuringInteraction');scheduleLodPass();if(inWorldOverview())scheduleWorldOverview(true);perfEnd('ensureBoards',started);return}
|
|||
|
|
let changes=0,pending=false;
|
|||
|
|
if(inWorldOverview()){
|
|||
|
|
for(const[,b]of rendered)if(b.drawing?.pointerId==null){if(changes<LOD_CHANGES_PER_PASS){destroyBoard(b);changes++}else pending=true}
|
|||
|
|
for(const b of staticRendered.values()){if(changes<LOD_CHANGES_PER_PASS){destroyStaticBoard(b);changes++}else pending=true}
|
|||
|
|
if(pending)scheduleLodPass();
|
|||
|
|
scheduleWorldOverview(true);perfEnd('ensureBoards',started);return;
|
|||
|
|
}
|
|||
|
|
if([...visibleMetaIds()].some(id=>data.metas[id]&&!data.metas[id].puzzle))void hydrateVisibleMetas().then(count=>{if(count)ensureBoards()});
|
|||
|
|
const ids=visibleMetaIds(),desiredInteractive=desiredInteractiveBoardIds(ids);
|
|||
|
|
for(const id of desiredInteractive){
|
|||
|
|
const meta=data.metas[id];if(!meta?.puzzle||rendered.has(id))continue;
|
|||
|
|
if(changes<LOD_CHANGES_PER_PASS){destroyStaticBoard(staticRendered.get(id));makeBoard(meta);changes++}else pending=true;
|
|||
|
|
}
|
|||
|
|
for(const[id,b]of rendered)if(!data.metas[id]||(b.drawing?.pointerId==null&&!ids.has(id))){if(changes<LOD_CHANGES_PER_PASS){destroyBoard(b);changes++}else pending=true}
|
|||
|
|
for(const[id,b]of staticRendered)if(!data.metas[id]||!ids.has(id)){if(changes<LOD_CHANGES_PER_PASS){destroyStaticBoard(b);changes++}else pending=true}
|
|||
|
|
if(pending)scheduleLodPass();
|
|||
|
|
const visibleUnsolvedBoards=[...ids].filter(id=>data.metas[id]?.puzzle&&!metaState(id).solved).length;
|
|||
|
|
perfGauge('visibleBoardIds',ids.size);perfGauge('visibleUnsolvedBoards',visibleUnsolvedBoards);perfGauge('interactiveBoardBudget',desiredInteractive.size);perfEnd('ensureBoards',started);
|
|||
|
|
}
|
|||
|
|
function renderAll(){for(const b of rendered.values())renderBoard(b)}
|
|||
|
|
let cam={x:0,y:0,scale:1},pan=null,pinch=null,cameraFrame=0,cameraFrameDelayTimer=0,cameraLastDraw=0,cameraInteractionFrame=0,cameraInteractionDelayTimer=0,cameraInteractionLastDraw=0,pendingCameraInteraction=null,visibilityTimer=0,previousViewportSize=null;
|
|||
|
|
function currentCameraAnchor(){const r=getViewportRect(),scale=Math.max(MIN_CAMERA_SCALE,Math.min(1.8,cam.scale||1));return{centerX:renderOriginX+(r.width/2-cam.x)/(UNIT*scale),centerY:renderOriginY+(r.height/2-cam.y)/(UNIT*scale),scale}}
|
|||
|
|
function recordCameraAnchor(){
|
|||
|
|
const next=currentCameraAnchor(),previous=data.cameraAnchor;
|
|||
|
|
if(previous&&Math.abs(previous.centerX-next.centerX)<1e-9&&Math.abs(previous.centerY-next.centerY)<1e-9&&Math.abs(previous.scale-next.scale)<1e-9)return false;
|
|||
|
|
data.cameraAnchor=next;markGlobalDirty(false);save();return true;
|
|||
|
|
}
|
|||
|
|
function restoreSavedCamera(){const anchor=data.cameraAnchor;if(!anchor||!Number.isFinite(anchor.centerX)||!Number.isFinite(anchor.centerY)||!Number.isFinite(anchor.scale))return false;const r=getViewportRect();cam.scale=Math.max(MIN_CAMERA_SCALE,Math.min(1.8,anchor.scale));cam.x=r.width/2-(anchor.centerX-renderOriginX)*UNIT*cam.scale;cam.y=r.height/2-(anchor.centerY-renderOriginY)*UNIT*cam.scale;applyCamera(true);previousViewportSize={width:r.width,height:r.height};return true}
|
|||
|
|
const touchPoints=new Map();
|
|||
|
|
function rebaseWorldOrigin(){
|
|||
|
|
const r=getViewportRect(),worldX=renderOriginX+(r.width/2-cam.x)/(UNIT*cam.scale),worldY=renderOriginY+(r.height/2-cam.y)/(UNIT*cam.scale);
|
|||
|
|
if(Math.abs(worldX-renderOriginX)<2048&&Math.abs(worldY-renderOriginY)<2048)return;
|
|||
|
|
const nextX=Math.trunc(worldX),nextY=Math.trunc(worldY),dx=nextX-renderOriginX,dy=nextY-renderOriginY;renderOriginX=nextX;renderOriginY=nextY;cam.x+=dx*UNIT*cam.scale;cam.y+=dy*UNIT*cam.scale;
|
|||
|
|
for(const b of[...rendered.values(),...staticRendered.values()]){b.card.style.left=((b.meta.x-renderOriginX)*UNIT-PAD)+'px';b.card.style.top=((b.meta.y-renderOriginY)*UNIT-PAD)+'px'}overviewDirty=true;
|
|||
|
|
}
|
|||
|
|
function repositionActiveBoardHud(){const board=rendered.get(hudBoardId);if(board?.card?.isConnected&&boardPlayHudVisible(board))positionBoardLabel(board)}
|
|||
|
|
function applyCamera(immediate=false,frameTimestamp=null){
|
|||
|
|
const cameraGestureActive=Boolean(pan||pinch||document.body.classList.contains('is-interacting'));if(!cameraGestureActive)rebaseWorldOrigin();const overview=updateZoomPresentation();
|
|||
|
|
const paint=timestamp=>{cameraFrame=0;cameraFrameDelayTimer=0;cameraLastDraw=timestamp;markVisualFrame(timestamp);if(overview)positionCachedWorldOverview();else world.style.transform=`translate3d(${cam.x}px,${cam.y}px,0) scale(${cam.scale})`};
|
|||
|
|
if(immediate){if(cameraFrame)cancelAnimationFrame(cameraFrame);if(cameraFrameDelayTimer)clearTimeout(cameraFrameDelayTimer);paint(frameTimestamp??perfNow())}
|
|||
|
|
else if(!cameraFrame)cameraFrame=requestAnimationFrame(paint);
|
|||
|
|
if(overview){overviewDirty=true;if(!cameraGestureActive)scheduleWorldOverview()}
|
|||
|
|
if(cameraGestureActive){if(visibilityTimer){clearTimeout(visibilityTimer);visibilityTimer=0}}else{clearTimeout(visibilityTimer);visibilityTimer=setTimeout(()=>{visibilityTimer=0;ensureBoards();repositionActiveBoardHud()},90)}
|
|||
|
|
minimapDirty=true;
|
|||
|
|
if(cameraGestureActive)shiftOnlineLayersForCamera();else{scheduleMinimap();redrawOnlineLayersAfterCamera()}
|
|||
|
|
scheduleRealtimeViewport();
|
|||
|
|
}
|
|||
|
|
function zoomAt(clientX,clientY,nextScale){
|
|||
|
|
const r=getViewportRect(),mx=clientX-r.left,my=clientY-r.top,wx=(mx-cam.x)/cam.scale,wy=(my-cam.y)/cam.scale;
|
|||
|
|
cam.scale=Math.max(MIN_CAMERA_SCALE,Math.min(1.8,nextScale));cam.x=mx-wx*cam.scale;cam.y=my-wy*cam.scale;applyCamera();
|
|||
|
|
}
|
|||
|
|
function centerMeta(meta,{preserveScale=false,select=true}={}){
|
|||
|
|
if(!meta?.puzzle)return false;
|
|||
|
|
const bounds=boardWorldBounds(meta),r=getViewportRect();
|
|||
|
|
worldOverviewActive=false;if(!preserveScale)cam.scale=Math.min(1.15,Math.max(.55,Math.min((r.width-90)/bounds.w,(r.height-90)/bounds.h)));
|
|||
|
|
cam.x=r.width/2-(bounds.x+bounds.w/2)*cam.scale;cam.y=r.height/2-(bounds.y+bounds.h/2)*cam.scale;updateZoomPresentation(true);applyCamera();
|
|||
|
|
previousViewportSize={width:r.width,height:r.height};ensureBoards();const board=rendered.get(meta.id);if(select&&board)selectBoard(board);recordCameraAnchor();return true;
|
|||
|
|
}
|
|||
|
|
function centerOrigin(){return centerMeta(data.metas.B0||Object.values(data.metas).find(meta=>meta.puzzle))}
|
|||
|
|
async function centerRandomBoard(){
|
|||
|
|
let candidates=Object.values(data.metas).filter(Boolean);
|
|||
|
|
if(candidates.length>1)candidates=candidates.filter(meta=>meta.id!==activeBoard);
|
|||
|
|
if(!candidates.length){toast('\u79fb\u52d5\u3067\u304d\u308b\u76e4\u9762\u306f\u3042\u308a\u307e\u305b\u3093\u3002');return false}
|
|||
|
|
const random=new Uint32Array(1);try{if(!globalThis.crypto?.getRandomValues)throw new Error('secure random unavailable');globalThis.crypto.getRandomValues(random)}catch(_){random[0]=Math.floor(Math.random()*0xffffffff)}
|
|||
|
|
const meta=candidates[random[0]%candidates.length];
|
|||
|
|
if(!meta.puzzle)try{await hydrateMeta(meta)}catch(error){toast('\u76e4\u9762\u3092\u8aad\u307f\u8fbc\u3081\u307e\u305b\u3093\u3002');return false}
|
|||
|
|
return centerMeta(meta);
|
|||
|
|
}
|
|||
|
|
function cancelPointerGestures(){
|
|||
|
|
for(const b of rendered.values())if(b.drawing?.pointerId!=null){const pointerId=b.drawing.pointerId;cancelBoardDragFrame(b);safeRelease(b.svg,pointerId);if(discardUnmovedCreatedPath(b)){renderBoard(b);continue}b.drawing.pointerId=null;b.drawing.lastPoint=null;b.drawing.pointerOffset=null;clearDrawingPointerState(b.drawing);changed(b.id);renderBoard(b)}
|
|||
|
|
}
|
|||
|
|
function touchPair(){
|
|||
|
|
const points=[...touchPoints.values()];if(points.length<2)return null;
|
|||
|
|
const[a,b]=points,center=[(a.x+b.x)/2,(a.y+b.y)/2],distance=Math.hypot(a.x-b.x,a.y-b.y);
|
|||
|
|
return{center,distance};
|
|||
|
|
}
|
|||
|
|
function commitCameraInteraction(timestamp=perfNow()){
|
|||
|
|
const started=perfStart();cameraInteractionFrame=0;cameraInteractionDelayTimer=0;cameraInteractionLastDraw=timestamp;const next=pendingCameraInteraction;pendingCameraInteraction=null;if(!next){perfEnd('commitCameraInteraction',started);return false}
|
|||
|
|
cam.x=next.x;cam.y=next.y;if(Number.isFinite(next.scale))cam.scale=next.scale;applyCamera(true,timestamp);recordInteractionCommit('camera',timestamp,next.inputAt);perfCount('cameraInteractionFrames');const workDuration=perfEnd('commitCameraInteraction',started);observeInteractionFrame(timestamp,workDuration);return true;
|
|||
|
|
}
|
|||
|
|
function queueCameraInteraction(next){
|
|||
|
|
pendingCameraInteraction=next;if(cameraInteractionFrame||cameraInteractionDelayTimer)return;
|
|||
|
|
const step=timestamp=>{
|
|||
|
|
if(!cameraInteractionFrame&&!cameraInteractionDelayTimer)return;
|
|||
|
|
if(cameraInteractionLastDraw&×tamp+INTERACTION_FRAME_TOLERANCE_MS<cameraInteractionLastDraw+DRAG_FRAME_INTERVAL){cameraInteractionFrame=requestAnimationFrame(step);return}
|
|||
|
|
if(cameraInteractionFrame)cancelAnimationFrame(cameraInteractionFrame);if(cameraInteractionDelayTimer)clearTimeout(cameraInteractionDelayTimer);cameraInteractionFrame=0;cameraInteractionDelayTimer=0;
|
|||
|
|
commitCameraInteraction(timestamp);
|
|||
|
|
};
|
|||
|
|
const arm=()=>{
|
|||
|
|
cameraInteractionDelayTimer=0;
|
|||
|
|
cameraInteractionFrame=requestAnimationFrame(step);
|
|||
|
|
cameraInteractionDelayTimer=setTimeout(()=>{perfCount('cameraDisplayWatchdogFrames');step(perfNow())},CAMERA_DISPLAY_WATCHDOG_MS);
|
|||
|
|
};
|
|||
|
|
arm();
|
|||
|
|
}
|
|||
|
|
function flushCameraInteraction(){
|
|||
|
|
if(cameraInteractionFrame){cancelAnimationFrame(cameraInteractionFrame);cameraInteractionFrame=0}if(cameraInteractionDelayTimer){clearTimeout(cameraInteractionDelayTimer);cameraInteractionDelayTimer=0}
|
|||
|
|
const committed=commitCameraInteraction();recordCameraAnchor();return committed;
|
|||
|
|
}
|
|||
|
|
function leftFieldPanAllowed(event){
|
|||
|
|
if(event.button!==0)return false;
|
|||
|
|
const target=event.target;if(target?.closest?.('button,a,input,select,textarea,[role="button"]'))return false;
|
|||
|
|
const card=target?.closest?.('.board-card');if(!card)return true;
|
|||
|
|
const id=card.dataset?.id;
|
|||
|
|
return card.classList?.contains('solved')||Boolean(id&&data.metas[id]&&metaState(id).solved);
|
|||
|
|
}
|
|||
|
|
function beginPan(e){
|
|||
|
|
if(e.pointerType==='touch'){
|
|||
|
|
touchPoints.set(e.pointerId,{x:e.clientX,y:e.clientY});
|
|||
|
|
if(touchPoints.size===2){
|
|||
|
|
e.preventDefault();e.stopPropagation();cancelPointerGestures();
|
|||
|
|
const pair=touchPair(),r=getViewportRect(),mx=pair.center[0]-r.left,my=pair.center[1]-r.top;
|
|||
|
|
pinch={startDistance:Math.max(1,pair.distance),startScale:cam.scale,worldX:(mx-cam.x)/cam.scale,worldY:(my-cam.y)/cam.scale,lastCenter:pair.center,lastAt:performance.now()};
|
|||
|
|
pan=null;viewport.classList.add('panning');refreshInteractionState();return;
|
|||
|
|
}
|
|||
|
|
if(touchPoints.size===1&&!e.target.closest?.('.board-svg,.board-static,.board-card button')){
|
|||
|
|
e.preventDefault();e.stopPropagation();pan={id:e.pointerId,x:e.clientX,y:e.clientY,cx:cam.x,cy:cam.y,lastX:e.clientX,lastY:e.clientY,lastAt:performance.now(),touch:true};viewport.classList.add('panning');
|
|||
|
|
try{viewport.setPointerCapture(e.pointerId)}catch(_){}refreshInteractionState();
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if(e.button!==2&&!leftFieldPanAllowed(e))return;
|
|||
|
|
e.preventDefault();e.stopPropagation();pan={id:e.pointerId,x:e.clientX,y:e.clientY,cx:cam.x,cy:cam.y,lastX:e.clientX,lastY:e.clientY,lastAt:performance.now(),touch:false,button:e.button};viewport.classList.add('panning');
|
|||
|
|
try{viewport.setPointerCapture(e.pointerId)}catch(_){}refreshInteractionState();
|
|||
|
|
}
|
|||
|
|
function movePan(e){
|
|||
|
|
if(e.pointerType==='touch'&&touchPoints.has(e.pointerId))touchPoints.set(e.pointerId,{x:e.clientX,y:e.clientY});
|
|||
|
|
if(pinch&&touchPoints.size>=2){
|
|||
|
|
e.preventDefault();e.stopPropagation();const pair=touchPair(),r=getViewportRect(),mx=pair.center[0]-r.left,my=pair.center[1]-r.top,
|
|||
|
|
nextScale=Math.max(.35,Math.min(1.8,pinch.startScale*pair.distance/pinch.startDistance));
|
|||
|
|
queueCameraInteraction({scale:nextScale,x:mx-pinch.worldX*nextScale,y:my-pinch.worldY*nextScale,inputAt:Number(e.timeStamp)||perfNow()});return;
|
|||
|
|
}
|
|||
|
|
if(!pan||pan.id!==e.pointerId)return;
|
|||
|
|
e.preventDefault();if(pan.touch)e.stopPropagation();
|
|||
|
|
queueCameraInteraction({scale:cam.scale,x:pan.cx+e.clientX-pan.x,y:pan.cy+e.clientY-pan.y,inputAt:Number(e.timeStamp)||perfNow()});
|
|||
|
|
}
|
|||
|
|
function stopPan(e){
|
|||
|
|
touchPoints.delete(e.pointerId);
|
|||
|
|
let ended=false;
|
|||
|
|
if(pinch&&touchPoints.size<2){flushCameraInteraction();pinch=null;ended=true}
|
|||
|
|
if(pan&&pan.id===e.pointerId){flushCameraInteraction();pan=null;ended=true;try{viewport.releasePointerCapture(e.pointerId)}catch(_){}}
|
|||
|
|
if(ended){viewport.classList.remove('panning');refreshInteractionState();clearTimeout(visibilityTimer);visibilityTimer=setTimeout(()=>{visibilityTimer=0;rebaseWorldOrigin();ensureBoards();repositionActiveBoardHud();scheduleWorldOverview(true)},60);scheduleMinimap(true);redrawOnlineLayersAfterCamera();scheduleRealtimeViewport(true)}
|
|||
|
|
}
|
|||
|
|
let tooltipFrame=0,pendingTooltipEvent=null,tooltipSize=null,tooltipText='',tooltipLastProbe=0;
|
|||
|
|
function hideSpecialTooltip(){pendingTooltipEvent=null;if(tooltipFrame){cancelAnimationFrame(tooltipFrame);tooltipFrame=0}if(specialTooltip)specialTooltip.hidden=true}
|
|||
|
|
function positionSpecialTooltip(event){
|
|||
|
|
if(!specialTooltip||specialTooltip.hidden)return;
|
|||
|
|
const view=globalThis.visualViewport,gap=14,pad=10,viewLeft=view?.offsetLeft||0,viewTop=view?.offsetTop||0,viewWidth=view?.width||innerWidth,viewHeight=view?.height||innerHeight,
|
|||
|
|
minLeft=viewLeft+pad,minTop=viewTop+pad,maxRight=viewLeft+viewWidth-pad,maxBottom=viewTop+viewHeight-pad;
|
|||
|
|
if(!tooltipSize)tooltipSize={width:specialTooltip.offsetWidth||260,height:specialTooltip.offsetHeight||42};
|
|||
|
|
const{width,height}=tooltipSize;
|
|||
|
|
let left=event.clientX+gap,top=event.clientY+gap;
|
|||
|
|
if(left+width>maxRight)left=event.clientX-width-gap;
|
|||
|
|
if(top+height>maxBottom)top=event.clientY-height-gap;
|
|||
|
|
left=Math.max(minLeft,Math.min(left,maxRight-width));top=Math.max(minTop,Math.min(top,maxBottom-height));
|
|||
|
|
specialTooltip.style.left=left+'px';specialTooltip.style.top=top+'px';
|
|||
|
|
}
|
|||
|
|
function specialInfoFromEvent(event){
|
|||
|
|
const card=event.target?.closest?.('.board-card'),b=card?rendered.get(card.dataset.id):null;if(!b||metaState(b.id).solved)return null;
|
|||
|
|
let cell=null;
|
|||
|
|
const directCell=event.target?.closest?.('[data-cell]')?.dataset.cell;
|
|||
|
|
if(directCell){const parts=directCell.split(',').map(Number);if(parts.length===2&&parts.every(Number.isFinite))cell=parts}
|
|||
|
|
if(!cell){const directGate=Number(event.target?.closest?.('.gate-hit')?.dataset.gate);if(Number.isInteger(directGate)&&b.p.g[directGate]){const gate=gateObj(b.p,directGate);cell=[gate.cell[0],gate.cell[1]]}}
|
|||
|
|
const point=eventToSvg(b,event);
|
|||
|
|
if(!cell)cell=cellAt(b,point);
|
|||
|
|
let info=cell?b.specialInfo?.get(ckey(...cell)):null;
|
|||
|
|
if(!info&&point&&b.specialInfo?.size){
|
|||
|
|
let nearest=null,nearestDistance=(CELL*.8)**2;
|
|||
|
|
for(const[key,candidate]of b.specialInfo){const rc=key.split(',').map(Number),center=boardCellCenter(rc),distance=(center[0]-point[0])**2+(center[1]-point[1])**2;if(distance<=nearestDistance){nearestDistance=distance;nearest={cell:rc,info:candidate}}}
|
|||
|
|
if(nearest){cell=nearest.cell;info=nearest.info}
|
|||
|
|
}
|
|||
|
|
return info?{b,cell,info}:null;
|
|||
|
|
}
|
|||
|
|
function updateSpecialTooltip(event){if(pan||pinch){hideSpecialTooltip();return}const target=specialInfoFromEvent(event);if(!target||!specialTooltip){hideSpecialTooltip();return}if(tooltipText!==target.info.description){tooltipText=target.info.description;specialTooltip.textContent=tooltipText;tooltipSize=null}specialTooltip.hidden=false;positionSpecialTooltip(event)}
|
|||
|
|
function queueSpecialTooltip(event){
|
|||
|
|
if(pan||pinch||document.body.classList.contains('is-drawing')){if(!specialTooltip?.hidden)hideSpecialTooltip();return}
|
|||
|
|
pendingTooltipEvent={target:event.target,clientX:event.clientX,clientY:event.clientY};
|
|||
|
|
if(tooltipFrame)return;
|
|||
|
|
const step=timestamp=>{if(timestamp-tooltipLastProbe<80){tooltipFrame=requestAnimationFrame(step);return}tooltipFrame=0;tooltipLastProbe=timestamp;const pending=pendingTooltipEvent;pendingTooltipEvent=null;if(pending)updateSpecialTooltip(pending)};
|
|||
|
|
tooltipFrame=requestAnimationFrame(step);
|
|||
|
|
}
|
|||
|
|
viewport.addEventListener('pointermove',queueSpecialTooltip,{passive:true});
|
|||
|
|
viewport.addEventListener('pointerleave',hideSpecialTooltip,{passive:true});
|
|||
|
|
viewport.addEventListener('pointerdown',hideSpecialTooltip,true);
|
|||
|
|
viewport.addEventListener('pointerdown',beginReactionGesture,true);
|
|||
|
|
viewport.addEventListener('pointerdown',beginPan,true);
|
|||
|
|
window.addEventListener('pointermove',moveReactionGesture,true);
|
|||
|
|
viewport.addEventListener('pointermove',movePan,true);
|
|||
|
|
window.addEventListener('pointerup',endReactionGesture,true);
|
|||
|
|
viewport.addEventListener('pointerup',stopPan,true);
|
|||
|
|
window.addEventListener('pointercancel',cancelReactionGesture,true);
|
|||
|
|
viewport.addEventListener('pointercancel',stopPan,true);
|
|||
|
|
viewport.addEventListener('contextmenu',e=>e.preventDefault());
|
|||
|
|
viewport.addEventListener('wheel',e=>{e.preventDefault();pulseWheelInteraction();zoomAt(e.clientX,e.clientY,cam.scale*Math.exp(-e.deltaY*.0012))},{passive:false});
|
|||
|
|
minimapCanvas.addEventListener('pointerdown',beginMinimapPointer);
|
|||
|
|
minimapCanvas.addEventListener('pointermove',moveMinimapPointer);
|
|||
|
|
minimapCanvas.addEventListener('pointerup',endMinimapPointer);
|
|||
|
|
minimapCanvas.addEventListener('pointercancel',endMinimapPointer);
|
|||
|
|
minimapCanvas.addEventListener('lostpointercapture',endMinimapPointer);
|
|||
|
|
minimapCanvas.addEventListener('keydown',keyMinimap);
|
|||
|
|
function handleViewportResize(){
|
|||
|
|
viewportResizeFrame=0;viewportRectCache=null;minimapRectCache=null;const r=getViewportRect(true);
|
|||
|
|
if(previousViewportSize){
|
|||
|
|
const worldCenterX=(previousViewportSize.width/2-cam.x)/cam.scale,worldCenterY=(previousViewportSize.height/2-cam.y)/cam.scale;
|
|||
|
|
cam.x=r.width/2-worldCenterX*cam.scale;cam.y=r.height/2-worldCenterY*cam.scale;
|
|||
|
|
}
|
|||
|
|
previousViewportSize={width:r.width,height:r.height};applyCamera();
|
|||
|
|
}
|
|||
|
|
function scheduleViewportResize(){viewportRectCache=null;minimapRectCache=null;if(!viewportResizeFrame)viewportResizeFrame=requestAnimationFrame(handleViewportResize)}
|
|||
|
|
window.addEventListener('resize',scheduleViewportResize);
|
|||
|
|
globalThis.visualViewport?.addEventListener?.('resize',scheduleViewportResize);
|
|||
|
|
if(typeof ResizeObserver!=='undefined')new ResizeObserver(scheduleViewportResize).observe(viewport);
|
|||
|
|
if(typeof ResizeObserver!=='undefined')new ResizeObserver(()=>{minimapRectCache=null;invalidateMinimapWorld()}).observe(minimapCanvas);
|
|||
|
|
const modal=document.querySelector('#modal'),helpBtn=document.querySelector('#helpBtn'),helpPanel=modal.querySelector('.panel'),
|
|||
|
|
storeModal=document.querySelector('#storeModal'),storePanel=storeModal.querySelector('.store-panel'),
|
|||
|
|
storeTitle=document.querySelector('#storeTitle'),storeWallet=document.querySelector('#storeWallet'),
|
|||
|
|
storeMeta=document.querySelector('#storeMeta'),storeInventory=document.querySelector('#storeInventory'),
|
|||
|
|
inventoryModal=document.querySelector('#inventoryModal'),inventoryPanel=inventoryModal.querySelector('.inventory-panel'),
|
|||
|
|
inventoryBtn=document.querySelector('#inventoryBtn'),inventoryCountEl=document.querySelector('#inventoryCount'),
|
|||
|
|
inventoryTotal=document.querySelector('#inventoryTotal'),inventoryTarget=document.querySelector('#inventoryTarget'),
|
|||
|
|
inventoryList=document.querySelector('#inventoryList'),debugAllItemsToggle=document.querySelector('#debugAllItemsToggle'),debugAllItemsState=document.querySelector('#debugAllItemsState'),
|
|||
|
|
timeAttackModal=document.querySelector('#timeAttackModal'),timeAttackPanel=timeAttackModal.querySelector('.time-attack-panel'),
|
|||
|
|
timeAttackBtn=document.querySelector('#timeAttackBtn'),timeAttackButtonLabel=document.querySelector('#timeAttackButtonLabel'),
|
|||
|
|
timeAttackHeadingScore=document.querySelector('#timeAttackHeadingScore'),timeAttackSetup=document.querySelector('#timeAttackSetup'),
|
|||
|
|
timeAttackLive=document.querySelector('#timeAttackLive'),timeAttackResultEl=document.querySelector('#timeAttackResult'),
|
|||
|
|
timeAttackClockEl=document.querySelector('#timeAttackClock'),timeAttackCollectedEl=document.querySelector('#timeAttackCollected'),
|
|||
|
|
timeAttackMultiplierEl=document.querySelector('#timeAttackMultiplier'),timeAttackProjectedEl=document.querySelector('#timeAttackProjected'),
|
|||
|
|
timeAttackResultLimit=document.querySelector('#timeAttackResultLimit'),timeAttackResultSolves=document.querySelector('#timeAttackResultSolves'),
|
|||
|
|
timeAttackResultCollected=document.querySelector('#timeAttackResultCollected'),timeAttackResultMultiplier=document.querySelector('#timeAttackResultMultiplier'),
|
|||
|
|
timeAttackResultBonus=document.querySelector('#timeAttackResultBonus'),timeAttackResultTotal=document.querySelector('#timeAttackResultTotal'),
|
|||
|
|
timeAttackShareText=document.querySelector('#timeAttackShareText'),copyTimeAttackBtn=document.querySelector('#copyTimeAttack'),
|
|||
|
|
closeTimeAttackBtn=document.querySelector('#closeTimeAttack'),
|
|||
|
|
settingsModal=document.querySelector('#settingsModal'),settingsPanel=settingsModal.querySelector('.settings-panel'),settingsPlayerName=document.querySelector('#settingsPlayerName'),lightweightRenderingToggle=document.querySelector('#lightweightRenderingToggle'),soundEnabledToggle=document.querySelector('#soundEnabledToggle'),saveSettingsBtn=document.querySelector('#saveSettings'),closeSettingsBtn=document.querySelector('#closeSettings'),customEmojiCursor=document.querySelector('#customEmojiCursor'),pickupHandleOverlay=document.querySelector('#pickupHandleOverlay');
|
|||
|
|
let openStoreBoardId=null;
|
|||
|
|
let timeAttackTimer=null,entryChoicePending=false;
|
|||
|
|
function trapDialogFocus(panel,e){
|
|||
|
|
if(e.key!=='Tab')return;
|
|||
|
|
const focusable=[...panel.querySelectorAll('button,[href],[tabindex]:not([tabindex="-1"])')].filter(el=>!el.disabled&&!el.hidden);
|
|||
|
|
if(!focusable.length){e.preventDefault();panel.focus();return}
|
|||
|
|
const first=focusable[0],last=focusable[focusable.length-1];
|
|||
|
|
if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus()}
|
|||
|
|
else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus()}
|
|||
|
|
}
|
|||
|
|
function setDialogInert(root,inert){
|
|||
|
|
root.inert=Boolean(inert);
|
|||
|
|
if(inert)root.setAttribute('inert','');
|
|||
|
|
else root.removeAttribute('inert');
|
|||
|
|
}
|
|||
|
|
function focusOutsideDialog(root,preferred=null){
|
|||
|
|
const active=document.activeElement;
|
|||
|
|
if(!active||!root.contains(active))return;
|
|||
|
|
const target=preferred?.isConnected&&!preferred.disabled?preferred:viewport;
|
|||
|
|
try{target?.focus?.({preventScroll:true})}catch(_){target?.focus?.()}
|
|||
|
|
if(root.contains(document.activeElement))active.blur?.();
|
|||
|
|
}
|
|||
|
|
function openDialogRoot(root){
|
|||
|
|
setDialogInert(root,false);
|
|||
|
|
root.setAttribute('aria-hidden','false');
|
|||
|
|
root.classList.add('show');
|
|||
|
|
}
|
|||
|
|
function closeDialogRoot(root,preferredFocus=null){
|
|||
|
|
focusOutsideDialog(root,preferredFocus);
|
|||
|
|
root.classList.remove('show');
|
|||
|
|
setDialogInert(root,true);
|
|||
|
|
root.setAttribute('aria-hidden','true');
|
|||
|
|
}
|
|||
|
|
function debugAllItemsEnabled(){return data.debugAllItems===true}
|
|||
|
|
function setItemIcon(node,item){
|
|||
|
|
node.classList.toggle('emoji-glyph',Boolean(item?.cursorEmoji&&!item?.flagAsset));
|
|||
|
|
if(item?.flagAsset){
|
|||
|
|
const image=document.createElement('img');image.className='item-flag-image';image.src=item.flagAsset;image.alt='';image.draggable=false;node.replaceChildren(image);
|
|||
|
|
}else node.textContent=item?.icon||'';
|
|||
|
|
}
|
|||
|
|
function renderInventoryPanel(){
|
|||
|
|
const debug=debugAllItemsEnabled(),total=debug?STORE_ITEMS.length:inventoryCount();
|
|||
|
|
inventoryCountEl.textContent=debug?'ALL':String(total);
|
|||
|
|
inventoryTotal.textContent=debug?'DEBUG':String(total);
|
|||
|
|
if(debugAllItemsToggle)debugAllItemsToggle.checked=debug;
|
|||
|
|
if(debugAllItemsState)debugAllItemsState.textContent=debug?'ON · 全アイテム使用可能':'OFF · 通常所持数を使用';
|
|||
|
|
inventoryTarget.textContent='カーソルはデザインをクリックして切り替えます。同じデザインをもう一度クリックすると標準へ戻ります。';
|
|||
|
|
inventoryList.replaceChildren();
|
|||
|
|
if(!total){
|
|||
|
|
const empty=document.createElement('div');empty.className='inventory-item empty';empty.textContent='所持アイテムはありません。';inventoryList.append(empty);return;
|
|||
|
|
}
|
|||
|
|
const available=STORE_ITEMS.filter(item=>debug||inventoryCount(item.id)>0),categories=[
|
|||
|
|
{title:'アイテム',items:available.filter(item=>!item.cursorStyle),cursor:false},
|
|||
|
|
{title:'カーソル',items:available.filter(item=>item.cursorStyle),cursor:true}
|
|||
|
|
];
|
|||
|
|
for(const category of categories){
|
|||
|
|
if(!category.items.length)continue;
|
|||
|
|
const section=document.createElement('section'),title=document.createElement('h3'),list=document.createElement('div');
|
|||
|
|
section.className=`inventory-section inventory-${category.cursor?'cursors':'items'}-section`;
|
|||
|
|
title.className='inventory-section-title';title.textContent=category.title;
|
|||
|
|
list.className=category.cursor?'inventory-cursor-grid':'inventory-item-list';
|
|||
|
|
for(const item of category.items){
|
|||
|
|
const count=inventoryCount(item.id);
|
|||
|
|
if(category.cursor){
|
|||
|
|
const option=document.createElement('button');option.type='button';option.className='inventory-cursor-option';setItemIcon(option,item);option.classList.toggle('selected',data.cursorStyle===item.cursorStyle);
|
|||
|
|
option.setAttribute('aria-pressed',String(data.cursorStyle===item.cursorStyle));option.setAttribute('aria-label',`${item.name}カーソル`);
|
|||
|
|
option.addEventListener('click',()=>useInventoryItem(item.id));list.append(option);continue;
|
|||
|
|
}
|
|||
|
|
const card=document.createElement('article'),icon=document.createElement('div'),copy=document.createElement('div'),
|
|||
|
|
name=document.createElement('h3'),description=document.createElement('p'),use=document.createElement('button');
|
|||
|
|
card.className=`inventory-item${debug?' debug-available':''}`;icon.className='inventory-item-icon';icon.textContent=item.icon;
|
|||
|
|
name.textContent=debug?`${item.name} · ∞`:`${item.name} · ×${count}`;description.textContent=item.description;copy.append(name,description);
|
|||
|
|
const lensActive=item.scoreLens&&data.scoreLensEnabled===true;
|
|||
|
|
use.type='button';use.className='inventory-use';use.textContent=item.scoreLens?(lensActive?'ON':'OFF'):'使用';use.disabled=false;
|
|||
|
|
if(item.scoreLens){use.classList.toggle('selected',lensActive);use.setAttribute('aria-pressed',String(lensActive))}
|
|||
|
|
use.addEventListener('click',()=>useInventoryItem(item.id));card.append(icon,copy,use);list.append(card);
|
|||
|
|
}
|
|||
|
|
section.append(title,list);inventoryList.append(section);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
function updateInventoryUi(){
|
|||
|
|
inventoryCountEl.textContent=debugAllItemsEnabled()?'ALL':String(inventoryCount());
|
|||
|
|
if(inventoryModal.classList.contains('show'))renderInventoryPanel();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function syncCursorAppearance(style){
|
|||
|
|
const selected=CURSOR_ITEM_BY_STYLE.get(style);
|
|||
|
|
document.body.dataset.cursorStyle=style||'default';document.body.dataset.cursorEmoji=selected?'on':'off';
|
|||
|
|
let nativeCursor='';
|
|||
|
|
if(selected?.flagAsset)nativeCursor=`url("${selected.flagAsset}") 16 16, auto`;
|
|||
|
|
else if(selected?.cursorEmoji){
|
|||
|
|
const svg=`<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><text x="2" y="27" font-size="26">${selected.cursorEmoji}</text></svg>`;
|
|||
|
|
nativeCursor=`url("data:image/svg+xml,${encodeURIComponent(svg)}") 16 16, auto`;
|
|||
|
|
}
|
|||
|
|
let forceDomFollower=false;try{forceDomFollower=localStorage.getItem('bend-field-cursor-renderer')==='dom'}catch(_){}
|
|||
|
|
const nativeSupported=Boolean(selected&&nativeCursor&&!forceDomFollower&&globalThis.CSS?.supports?.('cursor',nativeCursor));
|
|||
|
|
document.body.dataset.cursorMode=selected?(nativeSupported?'native':'dom'):'default';
|
|||
|
|
if(nativeSupported)document.body.style.setProperty('--active-native-cursor',nativeCursor);else document.body.style.removeProperty('--active-native-cursor');
|
|||
|
|
customEmojiCursor.classList.toggle('flag-cursor',Boolean(selected?.flagAsset));
|
|||
|
|
if(selected?.flagAsset){
|
|||
|
|
const image=document.createElement('img');image.src=selected.flagAsset;image.alt='';image.draggable=false;customEmojiCursor.replaceChildren(image);
|
|||
|
|
}else customEmojiCursor.textContent=selected?.cursorEmoji||'';
|
|||
|
|
if(!selected||nativeSupported){customEmojiCursor.classList.remove('visible');if(!selected)customEmojiCursor.style.transform=''}
|
|||
|
|
}
|
|||
|
|
function applyCursorStyle(style){data.cursorStyle=style||'default';syncCursorAppearance(data.cursorStyle);if(realtimePendingCursor){realtimePendingCursor.cursorStyle=data.cursorStyle;realtimeSend({type:'cursor',...realtimePendingCursor})}markGlobalDirty();}
|
|||
|
|
let customCursorFrame=0,customCursorX=0,customCursorY=0,customCursorInputAt=0,customCursorActiveUntil=0,customCursorRenderedX=NaN,customCursorRenderedY=NaN,customCursorLastFrameAt=0,customCursorInputRevision=0,customCursorCommittedRevision=0;
|
|||
|
|
function updateCustomCursorFromPointer(event){
|
|||
|
|
const customActive=document.body.dataset.cursorMode==='dom',insideDocument=event.target?.isConnected!==false,finePointer=event.pointerType!=='touch';
|
|||
|
|
if(!customActive||!insideDocument||!finePointer){customCursorRenderedX=customCursorRenderedY=NaN;customCursorLastFrameAt=0;customCursorActiveUntil=0;if(customEmojiCursor.classList.contains('visible'))customEmojiCursor.classList.remove('visible');return}
|
|||
|
|
const wasVisible=customEmojiCursor.classList.contains('visible');
|
|||
|
|
customCursorX=event.clientX;customCursorY=event.clientY;customCursorInputAt=Number(event.timeStamp)||perfNow();customCursorInputRevision++;customCursorActiveUntil=perfNow()+40;
|
|||
|
|
if(!wasVisible){customCursorRenderedX=customCursorX;customCursorRenderedY=customCursorY;customEmojiCursor.classList.add('visible')}
|
|||
|
|
if(customCursorFrame)return;
|
|||
|
|
const step=timestamp=>{
|
|||
|
|
if(!customCursorFrame)return;customCursorFrame=0;
|
|||
|
|
if(document.body.dataset.cursorMode!=='dom'||!customEmojiCursor.classList.contains('visible'))return;
|
|||
|
|
const elapsed=customCursorLastFrameAt?Math.min(50,Math.max(1,timestamp-customCursorLastFrameAt)):16.67,blend=1-Math.exp(-elapsed/8);
|
|||
|
|
if(!Number.isFinite(customCursorRenderedX)){customCursorRenderedX=customCursorX;customCursorRenderedY=customCursorY}
|
|||
|
|
else{customCursorRenderedX+=(customCursorX-customCursorRenderedX)*blend;customCursorRenderedY+=(customCursorY-customCursorRenderedY)*blend}
|
|||
|
|
customCursorLastFrameAt=timestamp;
|
|||
|
|
customEmojiCursor.style.transform=`translate3d(${customCursorRenderedX}px,${customCursorRenderedY}px,0) translate(-50%,-50%)`;markVisualFrame(timestamp);
|
|||
|
|
const freshInput=customCursorCommittedRevision!==customCursorInputRevision;recordInteractionCommit('cursor',timestamp,customCursorInputAt,freshInput);if(freshInput)customCursorCommittedRevision=customCursorInputRevision;perfCount('cursorCommits');
|
|||
|
|
if(perfNow()<customCursorActiveUntil)customCursorFrame=requestAnimationFrame(step);
|
|||
|
|
};
|
|||
|
|
customCursorFrame=requestAnimationFrame(step);
|
|||
|
|
}
|
|||
|
|
window.addEventListener('pointermove',updateCustomCursorFromPointer,{passive:true,capture:true});
|
|||
|
|
if('onpointerrawupdate'in window)window.addEventListener('pointerrawupdate',updateCustomCursorFromPointer,{passive:true,capture:true});
|
|||
|
|
window.addEventListener('pointerout',event=>{if(!event.relatedTarget)customEmojiCursor.classList.remove('visible')},{passive:true});
|
|||
|
|
function flashConfirmedCell(b,cell){
|
|||
|
|
if(!b?.svg?.isConnected||!Array.isArray(cell))return;
|
|||
|
|
const key=ckey(...cell),previous=b.cellFlashAnimations.get(key);if(previous)try{previous.cancel()}catch(_){}
|
|||
|
|
const node=svgEl('rect',{x:PAD+cell[1]*CELL,y:PAD+cell[0]*CELL,width:CELL,height:CELL,class:'cell-confirm-flash'});b.dragLayer.append(node);
|
|||
|
|
let animation=null,timer=0;const cleanup=()=>{node.remove();if(b.cellFlashAnimations.get(key)===controller)b.cellFlashAnimations.delete(key)};
|
|||
|
|
const controller={cancel(){if(animation)try{animation.cancel()}catch(_){}if(timer)clearTimeout(timer);cleanup()}};b.cellFlashAnimations.set(key,controller);
|
|||
|
|
if(typeof node.animate==='function'){try{animation=node.animate([{opacity:.75},{opacity:0}],{duration:260,easing:'ease-out'});animation.finished.catch(()=>{}).finally(cleanup)}catch(_){animation=null}}
|
|||
|
|
if(!animation)timer=setTimeout(cleanup,280);
|
|||
|
|
}
|
|||
|
|
function playConfirmTone(pathLength){const step=Math.min(7,Math.max(0,(pathLength||1)-1));soundTone(250*Math.pow(2,step/12),.055,{gain:.014,type:'sine',slide:1.03})}
|
|||
|
|
function finalizeExitGates(meta){const p=puzzleOf(meta),st=metaState(meta.id);if(!p||!st.solved)return;meta.sealedSides=[];meta.rev=nextRevision();markMetaDirty(meta.id)}
|
|||
|
|
let sessionCompletionCount=0,timeAttackSuggestionShown=false,timeAttackSuggestionPending=false,timeAttackSuggestionTimer=0;
|
|||
|
|
function timeAttackSuggestionReady(){
|
|||
|
|
if(data.timeAttackSuggestionsDisabled||data.timeAttack||timeAttackSuggestionShown||!timeAttackSuggestionPending)return false;
|
|||
|
|
const dialogOpen=[modal,storeModal,inventoryModal,timeAttackModal,settingsModal].some(root=>root?.classList.contains('show')),
|
|||
|
|
drawing=[...rendered.values()].some(board=>board.drawing?.pointerId!=null||board.drawing?.keyboardActive);
|
|||
|
|
return!dialogOpen&&!drawing;
|
|||
|
|
}
|
|||
|
|
function dismissTimeAttackSuggestion(){
|
|||
|
|
clearTimeout(timeAttackSuggestionTimer);timeAttackSuggestionTimer=0;if(timeAttackSuggestion)timeAttackSuggestion.hidden=true;
|
|||
|
|
}
|
|||
|
|
function tryShowTimeAttackSuggestion(){
|
|||
|
|
clearTimeout(timeAttackSuggestionTimer);timeAttackSuggestionTimer=0;
|
|||
|
|
if(!timeAttackSuggestionPending||timeAttackSuggestionShown)return false;
|
|||
|
|
if(data.timeAttackSuggestionsDisabled||data.timeAttack){timeAttackSuggestionPending=false;return false}
|
|||
|
|
if(!timeAttackSuggestionReady()){timeAttackSuggestionTimer=setTimeout(tryShowTimeAttackSuggestion,250);return false}
|
|||
|
|
timeAttackSuggestionPending=false;timeAttackSuggestionShown=true;timeAttackSuggestion.hidden=false;
|
|||
|
|
timeAttackSuggestionTimer=setTimeout(dismissTimeAttackSuggestion,7000);return true;
|
|||
|
|
}
|
|||
|
|
function scheduleTimeAttackSuggestionAfterCompletion(){
|
|||
|
|
sessionCompletionCount++;if(sessionCompletionCount!==10||timeAttackSuggestionShown)return false;
|
|||
|
|
timeAttackSuggestionPending=true;clearTimeout(timeAttackSuggestionTimer);timeAttackSuggestionTimer=setTimeout(tryShowTimeAttackSuggestion,700);return true;
|
|||
|
|
}
|
|||
|
|
function worldUnitAtClient(clientX,clientY){
|
|||
|
|
const rect=getViewportRect(),localX=(clientX-rect.left-cam.x)/cam.scale,localY=(clientY-rect.top-cam.y)/cam.scale;
|
|||
|
|
return[renderOriginX+localX/UNIT,renderOriginY+localY/UNIT];
|
|||
|
|
}
|
|||
|
|
async function useInventoryItemLoaded(itemId){
|
|||
|
|
const item=storeItem(itemId),debug=debugAllItemsEnabled(),entry=inventoryEntries(itemId)[0];
|
|||
|
|
if(!item||(!debug&&!entry)){toast('そのアイテムは所持していません。');renderInventoryPanel();return false}
|
|||
|
|
if(item.cursorStyle){
|
|||
|
|
const previousCursor=data.cursorStyle,nextCursor=previousCursor===item.cursorStyle?'default':item.cursorStyle;
|
|||
|
|
applyCursorStyle(nextCursor);
|
|||
|
|
if(!await save(true)){applyCursorStyle(previousCursor);renderInventoryPanel();toast('切り替えできませんでした。');return false}
|
|||
|
|
updateHud();renderInventoryPanel();toast(nextCursor==='default'?'標準カーソルへ戻しました。':`${item.name}へ切り替えました。`);return true;
|
|||
|
|
}
|
|||
|
|
if(item.scoreLens){
|
|||
|
|
const previous=data.scoreLensEnabled===true;data.scoreLensEnabled=!previous;markGlobalDirty();invalidateEconomyCaches();
|
|||
|
|
if(!await save(true)){data.scoreLensEnabled=previous;markGlobalDirty();invalidateEconomyCaches();renderInventoryPanel();toast('切り替えできませんでした。');return false}
|
|||
|
|
updateHud();renderAll();updateZoomPresentation(true);renderInventoryPanel();toast(`${item.name} ${data.scoreLensEnabled?'ON':'OFF'}`);return true;
|
|||
|
|
}
|
|||
|
|
const previous=entry?deepClone(entry.purchase):null,previousRev=entry?.st?.rev;
|
|||
|
|
if(!await save(true)){
|
|||
|
|
if(entry&&previous){Object.assign(entry.purchase,previous);entry.st.rev=previousRev;markStateDirty(entry.meta.id)}
|
|||
|
|
renderInventoryPanel();toast('使用できませんでした。');return false;
|
|||
|
|
}
|
|||
|
|
updateHud();renderAll();renderInventoryPanel();toast(`${item.name}を装備しました。`);return true;
|
|||
|
|
}
|
|||
|
|
async function useInventoryItem(itemId){
|
|||
|
|
const entry=inventoryEntries(itemId)[0];
|
|||
|
|
if(entry?.meta&&!entry.meta.puzzle)await hydrateMeta(entry.meta);
|
|||
|
|
return useInventoryItemLoaded(itemId);
|
|||
|
|
}
|
|||
|
|
function openInventory(){
|
|||
|
|
if(modal.classList.contains('show'))closeHelp(false);
|
|||
|
|
if(storeModal.classList.contains('show'))closeStore(false);
|
|||
|
|
if(timeAttackModal.classList.contains('show'))closeTimeAttack(false);
|
|||
|
|
if(settingsModal.classList.contains('show'))closeSettings(false);
|
|||
|
|
renderInventoryPanel();
|
|||
|
|
openDialogRoot(inventoryModal);inventoryBtn.setAttribute('aria-expanded','true');
|
|||
|
|
requestAnimationFrame(()=>inventoryPanel.focus());
|
|||
|
|
}
|
|||
|
|
function closeInventory(restoreFocus=true){
|
|||
|
|
closeDialogRoot(inventoryModal,restoreFocus?inventoryBtn:viewport);inventoryBtn.setAttribute('aria-expanded','false');
|
|||
|
|
}
|
|||
|
|
function formatTimeAttackClock(milliseconds){
|
|||
|
|
const seconds=Math.max(0,Math.ceil(milliseconds/1000)),minutes=Math.floor(seconds/60);
|
|||
|
|
return`${String(minutes).padStart(2,'0')}:${String(seconds%60).padStart(2,'0')}`;
|
|||
|
|
}
|
|||
|
|
function timeAttackResultText(result=data.lastTimeAttack){
|
|||
|
|
if(!result)return'';
|
|||
|
|
const lines=[
|
|||
|
|
'⏱️ 曲線フィールド|タイムアタック',
|
|||
|
|
`🏁 ${result.durationMinutes}分 🧩 ${result.solves}枚クリア`,
|
|||
|
|
`💎 獲得 ${formatScore(result.collected)} ✨ 実効倍率 ×${result.multiplier.toFixed(2)}`,
|
|||
|
|
`🎁 タイム加算 +${formatScore(result.modifierGain||0)}`,
|
|||
|
|
`🏆 合計 ${formatScore(result.total)}`
|
|||
|
|
];
|
|||
|
|
// lines.push('https://example.com/bend-field/time-attack/demo-result');
|
|||
|
|
return lines.join('\n');
|
|||
|
|
}
|
|||
|
|
function timeAttackCooldownRemaining(durationMinutes,now=trustedNow()){
|
|||
|
|
return Math.max(0,(data.timeAttackCooldowns?.[durationMinutes]||0)-now);
|
|||
|
|
}
|
|||
|
|
function hasActiveTimeAttackCooldown(){return TIME_ATTACK_MINUTES.some(minutes=>timeAttackCooldownRemaining(minutes)>0)}
|
|||
|
|
function renderTimeAttackPanel(){
|
|||
|
|
const run=data.timeAttack,last=data.lastTimeAttack;
|
|||
|
|
timeAttackSetup.hidden=!!run;
|
|||
|
|
timeAttackLive.hidden=!run;
|
|||
|
|
timeAttackResultEl.hidden=!!run||!last;
|
|||
|
|
copyTimeAttackBtn.hidden=!!run||!last;
|
|||
|
|
closeTimeAttackBtn.textContent='\u9589\u3058\u308b';
|
|||
|
|
for(const button of timeAttackModal.querySelectorAll('[data-time-minutes]')){
|
|||
|
|
const minutes=Number(button.dataset.timeMinutes),remaining=timeAttackCooldownRemaining(minutes),label=button.querySelector('.duration-status'),idleLabel=minutes===3?'短時間で集中':minutes===5?'標準コース':'じっくり挑戦';
|
|||
|
|
button.disabled=remaining>0;
|
|||
|
|
if(label)label.textContent=remaining?`待機 ${formatTimeAttackClock(remaining)}`:idleLabel;
|
|||
|
|
button.title=remaining?`\u518d\u6311\u6226\u307e\u3067\uff1a${formatTimeAttackClock(remaining)}`:`${minutes}\u5206\u306e\u30bf\u30a4\u30e0\u30a2\u30bf\u30c3\u30af\u3092\u958b\u59cb`;
|
|||
|
|
}
|
|||
|
|
if(run){
|
|||
|
|
const remaining=Math.max(0,run.endsAt-trustedNow()),multiplier=timeAttackMultiplier(run.baseCollected||0);
|
|||
|
|
timeAttackClockEl.textContent=formatTimeAttackClock(remaining);
|
|||
|
|
timeAttackClockEl.classList.toggle('urgent',remaining<=30000);
|
|||
|
|
timeAttackCollectedEl.textContent=formatScore(run.collected);
|
|||
|
|
timeAttackMultiplierEl.textContent=`\u00d7${multiplier.toFixed(2)}`;
|
|||
|
|
timeAttackProjectedEl.textContent=formatScore(run.collected);
|
|||
|
|
timeAttackHeadingScore.textContent=`${run.solves}\u679a\u30af\u30ea\u30a2`;
|
|||
|
|
}else{
|
|||
|
|
timeAttackClockEl.classList.remove('urgent');
|
|||
|
|
timeAttackHeadingScore.textContent=last?`\u30bf\u30a4\u30e0\u52a0\u7b97 +${formatScore(last.modifierGain||last.bonus)}`:'\u30bf\u30a4\u30e0\u52a0\u7b97';
|
|||
|
|
}
|
|||
|
|
if(last){
|
|||
|
|
timeAttackResultLimit.textContent=`${last.durationMinutes}\u5206`;
|
|||
|
|
timeAttackResultSolves.textContent=String(last.solves);
|
|||
|
|
timeAttackResultCollected.textContent=formatScore(last.collected);
|
|||
|
|
timeAttackResultMultiplier.textContent=`\u00d7${last.multiplier.toFixed(2)}`;
|
|||
|
|
timeAttackResultBonus.textContent=`+${formatScore(last.modifierGain||last.bonus)}`;
|
|||
|
|
timeAttackResultTotal.textContent=formatScore(last.total);
|
|||
|
|
timeAttackShareText.textContent=timeAttackResultText(last);
|
|||
|
|
}else timeAttackShareText.textContent='';
|
|||
|
|
}
|
|||
|
|
function updateTimeAttackUi(){
|
|||
|
|
const run=data.timeAttack,now=trustedNow();
|
|||
|
|
if(run&&now>=run.endsAt){void finishTimeAttack();return}
|
|||
|
|
if(!run&&!hasActiveTimeAttackCooldown())clearTimeAttackTimer()
|
|||
|
|
timeAttackBtn.classList.toggle('active',!!run);
|
|||
|
|
timeAttackBtn.classList.toggle('starting',Boolean(run&&now-run.startedAt<5000));
|
|||
|
|
timeAttackBtn.setAttribute('aria-label',run?`\u6b8b\u308a\u6642\u9593 ${formatTimeAttackClock(run.endsAt-now)}`:'\u30bf\u30a4\u30e0\u30a2\u30bf\u30c3\u30af\u3092\u958b\u304f');
|
|||
|
|
timeAttackButtonLabel.textContent=run?`${formatTimeAttackClock(run.endsAt-now)}`:'\u30bf\u30a4\u30e0\u30a2\u30bf\u30c3\u30af';
|
|||
|
|
if(timeAttackModal.classList.contains('show'))renderTimeAttackPanel();
|
|||
|
|
}
|
|||
|
|
function clearTimeAttackTimer(){if(timeAttackTimer){clearInterval(timeAttackTimer);clearTimeout(timeAttackTimer);timeAttackTimer=null}}
|
|||
|
|
function resumeTimeAttackTimer(){
|
|||
|
|
clearTimeAttackTimer();updateTimeAttackUi();
|
|||
|
|
if(data.timeAttack){timeAttackTimer=setInterval(updateTimeAttackUi,250);return}
|
|||
|
|
const remaining=TIME_ATTACK_MINUTES.map(minutes=>timeAttackCooldownRemaining(minutes)).filter(Boolean);
|
|||
|
|
if(!remaining.length)return;
|
|||
|
|
if(timeAttackModal.classList.contains('show'))timeAttackTimer=setInterval(updateTimeAttackUi,1000);
|
|||
|
|
else timeAttackTimer=setTimeout(resumeTimeAttackTimer,Math.min(...remaining)+50);
|
|||
|
|
}
|
|||
|
|
async function startTimeAttack(durationMinutes){
|
|||
|
|
if(data.timeAttack||!TIME_ATTACK_MINUTES.includes(durationMinutes))return false;
|
|||
|
|
const cooldown=timeAttackCooldownRemaining(durationMinutes);
|
|||
|
|
if(cooldown){toast(`\u518d\u6311\u6226\u307e\u3067\uff1a${formatTimeAttackClock(cooldown)}`);return false}
|
|||
|
|
const startedAt=trustedNow();
|
|||
|
|
const previous={timeAttack:data.timeAttack,timeAttackRev:data.timeAttackRev};
|
|||
|
|
data.timeAttack={id:globalThis.crypto?.randomUUID?.()||`run-${startedAt}`,durationMinutes,startedAt,endsAt:startedAt+durationMinutes*60000,collected:0,baseCollected:0,rewardPipelineVersion:2,scoreVersion:SCORE_VERSION,solves:0};
|
|||
|
|
data.timeAttackRev=nextRevision();markGlobalDirty();
|
|||
|
|
if(!await save(true)){data.timeAttack=previous.timeAttack;data.timeAttackRev=previous.timeAttackRev;toast('\u30bf\u30a4\u30e0\u30a2\u30bf\u30c3\u30af\u3092\u958b\u59cb\u3067\u304d\u307e\u305b\u3093\u3002');return false}
|
|||
|
|
resumeTimeAttackTimer();closeTimeAttack(false);updateHud();
|
|||
|
|
toast(`${durationMinutes}\u5206\u306e\u30bf\u30a4\u30e0\u30a2\u30bf\u30c3\u30af\u3092\u958b\u59cb\u3057\u307e\u3057\u305f\u3002 \u500d\u7387 \u00d71.25`);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
function recordTimeAttackScore(reward){
|
|||
|
|
const run=data.timeAttack;
|
|||
|
|
if(!run)return false;
|
|||
|
|
if(trustedNow()>=run.endsAt){void finishTimeAttack();return false}
|
|||
|
|
const award=Math.max(0,Math.round(typeof reward==='object'?reward.award:reward||0));
|
|||
|
|
const baseAward=Math.max(0,Math.round(typeof reward==='object'?reward.preTimeAward:award));
|
|||
|
|
run.baseCollected=Math.min(MAX_SCORE,(run.baseCollected||0)+baseAward);
|
|||
|
|
run.collected=Math.min(MAX_SCORE,run.collected+award);
|
|||
|
|
run.solves=Math.min(MAX_SOLVES,run.solves+1);
|
|||
|
|
data.timeAttackRev=nextRevision();
|
|||
|
|
updateTimeAttackUi();
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
let finishingTimeAttack=false;
|
|||
|
|
async function finishTimeAttack(){
|
|||
|
|
if(finishingTimeAttack)return null;
|
|||
|
|
const run=data.timeAttack;if(!run)return null;finishingTimeAttack=true;
|
|||
|
|
const previous={run:deepClone(run),lastTimeAttack:deepClone(data.lastTimeAttack),cooldowns:deepClone(data.timeAttackCooldowns),bonusScore:data.bonusScore,bonusEvents:deepClone(data.bonusEvents),timeAttackRev:data.timeAttackRev};
|
|||
|
|
try{
|
|||
|
|
const result=normalizeTimeAttackResult({...run,completedAt:trustedNow()});data.timeAttack=null;data.lastTimeAttack=result;
|
|||
|
|
data.timeAttackCooldowns=normalizeTimeAttackCooldowns(data.timeAttackCooldowns);data.timeAttackCooldowns[result.durationMinutes]=result.completedAt+TIME_ATTACK_COOLDOWN_MINUTES[result.durationMinutes]*60000;
|
|||
|
|
data.bonusEvents=data.bonusEvents||{};if(result.bonus>0)data.bonusEvents[`time-attack:${result.id}`]=result.bonus;data.bonusScore=bonusEventTotal();statsDirty=true;data.timeAttackRev=nextRevision();markGlobalDirty();clearTimeAttackTimer();
|
|||
|
|
if(!await save(true)){data.timeAttack=previous.run;data.lastTimeAttack=previous.lastTimeAttack;data.timeAttackCooldowns=previous.cooldowns;data.bonusScore=previous.bonusScore;data.bonusEvents=previous.bonusEvents;data.timeAttackRev=previous.timeAttackRev;statsDirty=true;resumeTimeAttackTimer();showStatus('\u4fdd\u5b58\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002',{retry:true,fresh:false});return null}
|
|||
|
|
updateHud();resumeTimeAttackTimer();openTimeAttack();toast(`\u30bf\u30a4\u30e0\u30a2\u30bf\u30c3\u30af\u7d42\u4e86 \u00b7 \u30bf\u30a4\u30e0\u52a0\u7b97 +${formatScore(result.modifierGain||result.bonus)}`,3000);return result;
|
|||
|
|
}finally{finishingTimeAttack=false}
|
|||
|
|
}
|
|||
|
|
async function copyTimeAttackResult(){
|
|||
|
|
const text=timeAttackResultText();
|
|||
|
|
if(!text)return false;
|
|||
|
|
let copied=false;
|
|||
|
|
try{await navigator.clipboard.writeText(text);copied=true}
|
|||
|
|
catch(_){
|
|||
|
|
const area=document.createElement('textarea');
|
|||
|
|
area.value=text;area.setAttribute('readonly','');area.style.position='fixed';area.style.opacity='0';
|
|||
|
|
document.body.append(area);area.select();
|
|||
|
|
try{copied=document.execCommand('copy')}catch(__){copied=false}
|
|||
|
|
area.remove();
|
|||
|
|
}
|
|||
|
|
toast(copied?'\u7d50\u679c\u3092\u30b3\u30d4\u30fc\u3057\u307e\u3057\u305f\u3002':'\u7d50\u679c\u3092\u30b3\u30d4\u30fc\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002');
|
|||
|
|
return copied;
|
|||
|
|
}
|
|||
|
|
function openTimeAttack(){
|
|||
|
|
if(modal.classList.contains('show'))closeHelp(false);
|
|||
|
|
if(storeModal.classList.contains('show'))closeStore(false);
|
|||
|
|
if(inventoryModal.classList.contains('show'))closeInventory(false);
|
|||
|
|
if(settingsModal.classList.contains('show'))closeSettings(false);
|
|||
|
|
renderTimeAttackPanel();
|
|||
|
|
openDialogRoot(timeAttackModal);timeAttackBtn.setAttribute('aria-expanded','true');
|
|||
|
|
resumeTimeAttackTimer();requestAnimationFrame(()=>timeAttackPanel.focus());
|
|||
|
|
}
|
|||
|
|
function closeTimeAttack(restoreFocus=true){
|
|||
|
|
closeDialogRoot(timeAttackModal,restoreFocus?timeAttackBtn:viewport);timeAttackBtn.setAttribute('aria-expanded','false');
|
|||
|
|
resumeTimeAttackTimer();
|
|||
|
|
}
|
|||
|
|
function openHelp(entry=false){
|
|||
|
|
if(storeModal.classList.contains('show'))closeStore(false);
|
|||
|
|
if(inventoryModal.classList.contains('show'))closeInventory(false);
|
|||
|
|
if(timeAttackModal.classList.contains('show'))closeTimeAttack(false);
|
|||
|
|
if(settingsModal.classList.contains('show'))closeSettings(false);
|
|||
|
|
entryChoicePending=entry===true;modal.classList.toggle('entry-choice',entryChoicePending);
|
|||
|
|
openDialogRoot(modal);helpBtn.setAttribute('aria-expanded','true');
|
|||
|
|
requestAnimationFrame(()=>helpPanel.focus());
|
|||
|
|
}
|
|||
|
|
function closeHelp(restoreFocus=true,force=false){
|
|||
|
|
if(entryChoicePending&&!force)return false;
|
|||
|
|
closeDialogRoot(modal,restoreFocus?helpBtn:viewport);modal.classList.remove('entry-choice');helpBtn.setAttribute('aria-expanded','false');
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
function chooseNormalPlay(){entryChoicePending=false;closeHelp(false,true);viewport.focus?.()}
|
|||
|
|
function chooseTimeAttack(){entryChoicePending=false;closeHelp(false,true);openTimeAttack()}
|
|||
|
|
|
|||
|
|
function openSettings(){
|
|||
|
|
if(modal.classList.contains('show'))closeHelp(false,true);
|
|||
|
|
if(storeModal.classList.contains('show'))closeStore(false);
|
|||
|
|
if(inventoryModal.classList.contains('show'))closeInventory(false);
|
|||
|
|
if(timeAttackModal.classList.contains('show'))closeTimeAttack(false);
|
|||
|
|
settingsPlayerName.value=currentPlayerName();lightweightRenderingToggle.checked=uiSettings.lightweightRendering;soundEnabledToggle.checked=uiSettings.soundEnabled;
|
|||
|
|
openDialogRoot(settingsModal);settingsBtn?.setAttribute('aria-expanded','true');requestAnimationFrame(()=>{settingsPanel.focus();settingsPlayerName.select()});
|
|||
|
|
}
|
|||
|
|
function closeSettings(restoreFocus=true){closeDialogRoot(settingsModal,restoreFocus?(settingsBtn||playerNameBtn):viewport);settingsBtn?.setAttribute('aria-expanded','false')}
|
|||
|
|
async function saveSettings(){
|
|||
|
|
const name=settingsPlayerName.value.replace(/[\u0000-\u001f\u007f]/g,'').replace(/\s+/g,' ').trim().slice(0,24);if(!name){toast('プレイヤー名を入力してください。');settingsPlayerName.focus();return false}
|
|||
|
|
if(name!==currentPlayerName()){
|
|||
|
|
if(cloudAvailable&&data.cloudProfile)try{const result=await fetchJson('/api/cloud/profile',{method:'POST',headers:cloudAuthHeaders(),body:JSON.stringify({name})});data.playerName=result.name||name;setCloudStatus('共有中','saved')}catch(error){toast(`名前を変更できませんでした。 ${error.message}`);return false}
|
|||
|
|
else data.playerName=name;markGlobalDirty(false);await persistNow({skipCloud:true});updatePlayerNameUi();renderAll();schedulePresenceRender(true);
|
|||
|
|
}
|
|||
|
|
const previousLightweight=uiSettings.lightweightRendering;uiSettings=normalizeUiSettings({lightweightRendering:lightweightRenderingToggle.checked,soundEnabled:soundEnabledToggle.checked});persistUiSettings();if(previousLightweight!==uiSettings.lightweightRendering){for(const board of rendered.values())board.solvedPathsRendered=false;renderAll();}closeSettings();toast('設定を保存しました。');return true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderStorePanel(){
|
|||
|
|
pruneAndCount();
|
|||
|
|
const meta=data.metas[openStoreBoardId],st=meta?metaState(meta.id):null,store=st?.store;
|
|||
|
|
if(!meta||!store){closeStore(false);return}
|
|||
|
|
storeTitle.textContent='\u30a2\u30a4\u30c6\u30e0\u30b7\u30e7\u30c3\u30d7';
|
|||
|
|
storeWallet.textContent=formatScore(data.score);
|
|||
|
|
storeMeta.textContent=`店主:${store.owner}`;
|
|||
|
|
storeInventory.replaceChildren();
|
|||
|
|
const available=storeInventoryItems(meta,store),categories=[
|
|||
|
|
{title:'アイテム',items:available.filter(item=>!item.cursorStyle),cursor:false},
|
|||
|
|
{title:'カーソル',items:available.filter(item=>item.cursorStyle),cursor:true}
|
|||
|
|
];
|
|||
|
|
for(const category of categories){
|
|||
|
|
const section=document.createElement('section'),title=document.createElement('h3'),list=document.createElement('div');
|
|||
|
|
section.className=`store-section store-${category.cursor?'cursors':'items'}-section`;title.className='store-section-title';title.textContent=category.title;
|
|||
|
|
list.className=`store-section-list ${category.cursor?'store-cursor-list':'store-item-list'}`;
|
|||
|
|
for(const item of category.items){
|
|||
|
|
const purchased=onlinePlayerEconomy()?Boolean(playerPurchaseForStore(meta.id,item.id)):store.purchases.some(purchase=>purchase.id===item.id),price=storeItemPrice(meta,store,item),
|
|||
|
|
card=document.createElement('article'),icon=document.createElement('div'),buy=document.createElement('button');
|
|||
|
|
card.className=`store-item ${category.cursor?'store-cursor':'store-other'}${purchased?' purchased':''}`;
|
|||
|
|
icon.className='store-item-icon';setItemIcon(icon,item);
|
|||
|
|
buy.type='button';buy.className='store-buy';
|
|||
|
|
buy.textContent=purchased?'\u8cfc\u5165\u6e08\u307f':`購入 ${formatScore(price)}`;
|
|||
|
|
buy.disabled=purchased||data.score<price;buy.setAttribute('aria-label',`${item.name}:${buy.textContent}`);
|
|||
|
|
buy.addEventListener('click',()=>purchaseStoreItem(meta.id,item.id));
|
|||
|
|
if(category.cursor)card.append(icon,buy);
|
|||
|
|
else{
|
|||
|
|
const copy=document.createElement('div'),name=document.createElement('h4'),effect=document.createElement('strong'),description=document.createElement('p');
|
|||
|
|
copy.className='store-item-copy';name.textContent=item.name;effect.textContent=item.effectLabel;description.textContent=item.description;
|
|||
|
|
copy.append(name,effect,description);card.append(icon,copy,buy);
|
|||
|
|
}
|
|||
|
|
list.append(card);
|
|||
|
|
}
|
|||
|
|
section.append(title,list);storeInventory.append(section);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
function openStoreMeta(meta){
|
|||
|
|
if(meta&&!meta.puzzle){void hydrateMeta(meta).then(()=>openStoreMeta(meta)).catch(error=>{console.warn('BEND FIELD: shop hydration failed',error);toast('\u30b7\u30e7\u30c3\u30d7\u3092\u8aad\u307f\u8fbc\u3081\u307e\u305b\u3093\u3067\u3057\u305f\u3002')});return true}
|
|||
|
|
const store=meta&&metaState(meta.id).store;if(!store)return false;
|
|||
|
|
if(modal.classList.contains('show'))closeHelp(false);
|
|||
|
|
if(inventoryModal.classList.contains('show'))closeInventory(false);
|
|||
|
|
if(timeAttackModal.classList.contains('show'))closeTimeAttack(false);
|
|||
|
|
if(settingsModal.classList.contains('show'))closeSettings(false);
|
|||
|
|
openStoreBoardId=meta.id;renderStorePanel();openDialogRoot(storeModal);requestAnimationFrame(()=>storePanel.focus());return true;
|
|||
|
|
}
|
|||
|
|
function openStore(board){return openStoreMeta(board?.meta)}
|
|||
|
|
function closeStore(restoreFocus=true){
|
|||
|
|
const board=rendered.get(openStoreBoardId);
|
|||
|
|
closeDialogRoot(storeModal,restoreFocus&&board?.storeButton?.isConnected?board.storeButton:viewport);openStoreBoardId=null;
|
|||
|
|
}
|
|||
|
|
async function purchaseStoreItem(boardId,itemId){
|
|||
|
|
const item=storeItem(itemId);if(!item)return false;
|
|||
|
|
pruneAndCount();
|
|||
|
|
const meta=data.metas[boardId],st=meta?metaState(boardId):null,store=st?.store;if(!store)return false;
|
|||
|
|
if(!storeInventoryItems(meta,store).some(available=>available.id===item.id)){toast('この店では取り扱っていません。');return false}
|
|||
|
|
const already=onlinePlayerEconomy()?playerPurchaseForStore(boardId,itemId):store.purchases.find(purchase=>purchase.id===item.id);if(already){toast('購入済みです。');return false}
|
|||
|
|
const price=storeItemPrice(meta,store,item);if(data.score<price){toast(`所持数が不足しています。価格 ${formatScore(price)}`);renderStorePanel();return false}
|
|||
|
|
if(onlinePlayerEconomy())try{await buyPersonalStoreItem(boardId,itemId);playSound('buy');toast(`${item.name}を購入しました。`);return true}catch(error){toast(`購入できませんでした。 ${error.message}`);renderStorePanel();return false}
|
|||
|
|
const previousRev=st.rev,purchase={id:item.id,buyer:currentPlayerName(),boughtAt:trustedNow(),paidCost:price};
|
|||
|
|
store.purchases.push(purchase);st.rev=nextRevision();markStateDirty(boardId);
|
|||
|
|
if(!await save(true)){store.purchases.splice(store.purchases.indexOf(purchase),1);st.rev=previousRev;markStateDirty(boardId);updateHud();renderStorePanel();toast('購入できませんでした。');return false}
|
|||
|
|
updateHud();renderStorePanel();playSound('buy');toast(`${item.name}を購入しました。`);return true;
|
|||
|
|
}
|
|||
|
|
window.addEventListener('keydown',e=>{
|
|||
|
|
if(settingsModal.classList.contains('show')){
|
|||
|
|
if(e.key==='Escape'){e.preventDefault();closeSettings();return}
|
|||
|
|
if(e.key==='Enter'&&document.activeElement===settingsPlayerName){e.preventDefault();void saveSettings();return}
|
|||
|
|
trapDialogFocus(settingsPanel,e);return;
|
|||
|
|
}
|
|||
|
|
if(inventoryModal.classList.contains('show')){
|
|||
|
|
if(e.key==='Escape'){e.preventDefault();closeInventory();return}
|
|||
|
|
trapDialogFocus(inventoryPanel,e);return;
|
|||
|
|
}
|
|||
|
|
if(timeAttackModal.classList.contains('show')){
|
|||
|
|
if(e.key==='Escape'){e.preventDefault();closeTimeAttack();return}
|
|||
|
|
trapDialogFocus(timeAttackPanel,e);return;
|
|||
|
|
}
|
|||
|
|
if(storeModal.classList.contains('show')){
|
|||
|
|
if(e.key==='Escape'){e.preventDefault();closeStore();return}
|
|||
|
|
trapDialogFocus(storePanel,e);return;
|
|||
|
|
}
|
|||
|
|
if(modal.classList.contains('show')){
|
|||
|
|
if(e.key==='Escape'){e.preventDefault();closeHelp();return}
|
|||
|
|
trapDialogFocus(helpPanel,e);return;
|
|||
|
|
}
|
|||
|
|
if(e.key!=='Escape')return;
|
|||
|
|
const b=rendered.get(activeBoard);
|
|||
|
|
if(b?.drawing){e.preventDefault();b.drawing=null;changed(b.id);renderBoard(b);updateSelectedProgress(b);queueMicrotask(reconcileDrawingPresentation);toast('\u7dda\u306e\u7de8\u96c6\u3092\u7d42\u4e86\u3057\u307e\u3057\u305f\u3002')}
|
|||
|
|
});
|
|||
|
|
helpBtn.onclick=openHelp;
|
|||
|
|
settingsBtn.onclick=openSettings;
|
|||
|
|
playerNameBtn.onclick=openSettings;
|
|||
|
|
saveSettingsBtn.onclick=()=>{void saveSettings()};
|
|||
|
|
closeSettingsBtn.onclick=()=>closeSettings();
|
|||
|
|
settingsModal.addEventListener('pointerdown',e=>{if(e.target===settingsModal)closeSettings()});
|
|||
|
|
inventoryBtn.onclick=openInventory;
|
|||
|
|
document.querySelector('#closeInventory').onclick=()=>closeInventory();
|
|||
|
|
inventoryModal.addEventListener('pointerdown',e=>{if(e.target===inventoryModal)closeInventory()});
|
|||
|
|
if(debugAllItemsToggle)debugAllItemsToggle.addEventListener('change',()=>{data.debugAllItems=debugAllItemsToggle.checked;markGlobalDirty();updateHud();void save(true);toast(data.debugAllItems?'デバッグON · 全アイテムを使用できます。':'デバッグOFF · 通常所持数へ戻りました。')});
|
|||
|
|
document.querySelector('#acceptTimeAttackSuggestion').onclick=()=>{dismissTimeAttackSuggestion();openTimeAttack()};
|
|||
|
|
document.querySelector('#dismissTimeAttackSuggestion').onclick=dismissTimeAttackSuggestion;
|
|||
|
|
timeAttackBtn.onclick=openTimeAttack;
|
|||
|
|
for(const button of timeAttackModal.querySelectorAll('[data-time-minutes]'))button.addEventListener('click',()=>startTimeAttack(Number(button.dataset.timeMinutes)));
|
|||
|
|
copyTimeAttackBtn.onclick=copyTimeAttackResult;
|
|||
|
|
closeTimeAttackBtn.onclick=()=>closeTimeAttack();
|
|||
|
|
timeAttackModal.addEventListener('pointerdown',e=>{if(e.target===timeAttackModal)closeTimeAttack()});
|
|||
|
|
document.querySelector('#normalPlayBtn').onclick=chooseNormalPlay;
|
|||
|
|
document.querySelector('#helpTimeAttackBtn').onclick=chooseTimeAttack;
|
|||
|
|
modal.addEventListener('pointerdown',e=>{if(e.target===modal&&!entryChoicePending)closeHelp()});
|
|||
|
|
document.querySelector('#closeStore').onclick=()=>closeStore();
|
|||
|
|
storeModal.addEventListener('pointerdown',e=>{if(e.target===storeModal)closeStore()});
|
|||
|
|
document.querySelector('#minimapOriginBtn').onclick=()=>centerOrigin();
|
|||
|
|
document.querySelector('#minimapRandomBtn').onclick=()=>{void centerRandomBoard()};
|
|||
|
|
function resetSelectedBoard(selected=rendered.get(hudBoardId)){
|
|||
|
|
const b=selected;if(!b)return false;const st=metaState(b.id);
|
|||
|
|
const hasSpecialProgress=!!st.specialProgress?.crossings?.length;
|
|||
|
|
if(st.solved||!st.paths.length&&!hasSpecialProgress){toast(st.solved?'\u30af\u30ea\u30a2\u6e08\u307f':'\u7dda\u304c\u3042\u308a\u307e\u305b\u3093\u3002');return false}
|
|||
|
|
const performReset=()=>{if(typeof touchBoardClaim==='function')touchBoardClaim(b.id,true);const pointerId=b.drawing?.pointerId;cancelBoardDragFrame(b);if(pointerId!=null)try{if(b.svg.hasPointerCapture?.(pointerId))b.svg.releasePointerCapture(pointerId)}catch(_){}
|
|||
|
|
applyBoardCommand(b,state=>{b.drawing=null;b.armedGate=null;b.solvedPathsRendered=false;state.paths=[];state.specialProgress={crossings:[]}},{persist:true,paint:false});
|
|||
|
|
renderBoardNow(b);updateSelectedProgress(b);queueMicrotask(reconcileDrawingPresentation);toast('\u76e4\u9762\u3092\u30ea\u30bb\u30c3\u30c8\u3057\u307e\u3057\u305f\u3002');return true};
|
|||
|
|
if(typeof cloudApiEnabled==='undefined'||typeof cloudAvailable==='undefined'||!cloudApiEnabled||!cloudAvailable)return performReset();
|
|||
|
|
return ensureBoardClaimForInput(b).then(ok=>ok?performReset():false);
|
|||
|
|
}
|
|||
|
|
world.addEventListener('click',event=>{
|
|||
|
|
const control=event.target.closest?.('.board-reset,.line-store');if(!control)return;
|
|||
|
|
const card=control.closest('.board-card'),board=card?rendered.get(card.dataset.id):null;if(!board)return;
|
|||
|
|
event.preventDefault();event.stopPropagation();selectBoard(board);
|
|||
|
|
if(control.classList.contains('board-reset')){playSound('reset');void resetSelectedBoard(board)}
|
|||
|
|
else{playSound('click');openStore(board)}
|
|||
|
|
});
|
|||
|
|
saveStatusEl.onclick=async()=>{if(await flushSave())toast('\u4fdd\u5b58\u3057\u307e\u3057\u305f')};
|
|||
|
|
let activeArchiveController=null,fieldArchiveBusy=false;
|
|||
|
|
const dismissStatusBtn=document.querySelector('#dismissStatusBtn'),exportBtn=document.querySelector('#exportBtn');
|
|||
|
|
function setFieldArchiveBusy(busy,message=''){
|
|||
|
|
fieldArchiveBusy=busy;document.body.classList.toggle('archive-busy',busy);
|
|||
|
|
for(const button of[exportBtn,document.querySelector('#importBtn'),document.querySelector('#freshBtn')])if(button)button.disabled=busy;
|
|||
|
|
if(dismissStatusBtn)dismissStatusBtn.textContent=busy?'\u30ad\u30e3\u30f3\u30bb\u30eb':'\u9589\u3058\u308b';
|
|||
|
|
if(message)showStatus(message,{retry:false,fresh:false});
|
|||
|
|
}
|
|||
|
|
dismissStatusBtn.onclick=()=>{if(activeArchiveController)activeArchiveController.abort();else hideStatus()};
|
|||
|
|
document.addEventListener('keydown',event=>{if(!fieldArchiveBusy)return;if(event.key==='Escape'&&activeArchiveController)activeArchiveController.abort();if(event.key!=='Tab'){event.preventDefault();event.stopImmediatePropagation()}},true);
|
|||
|
|
function parseJsonOrRaw(text){if(!text)return null;try{return JSON.parse(text)}catch(_){return{raw:text,parseError:true}}}
|
|||
|
|
function portableGlobalForArchive(source=data){
|
|||
|
|
return{
|
|||
|
|
gameplayVersion:GAMEPLAY_DATA_VERSION,
|
|||
|
|
bonusEvents:deepClone(source.bonusEvents||{}),
|
|||
|
|
specialMechanicsSeen:[...new Set(source.specialMechanicsSeen||[])].filter(type=>['warp','lock','crossing'].includes(type)).sort(),
|
|||
|
|
clockFloor:Math.max(0,Number(source.clockFloor)||0),
|
|||
|
|
lastSolveAt:Math.max(0,Number(source.lastSolveAt)||0),
|
|||
|
|
timeAttack:deepClone(source.timeAttack),
|
|||
|
|
timeAttackCooldowns:deepClone(source.timeAttackCooldowns),
|
|||
|
|
lastTimeAttack:deepClone(source.lastTimeAttack),
|
|||
|
|
timeAttackSuggestionsDisabled:source.timeAttackSuggestionsDisabled===true,
|
|||
|
|
cursorStyle:typeof source.cursorStyle==='string'?source.cursorStyle.slice(0,32):'default',
|
|||
|
|
scoreLensEnabled:source.scoreLensEnabled===true
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
function portableMetaForArchive(meta){
|
|||
|
|
const value=metaForStorage(meta);delete value.rev;delete value.revAuthor;return value;
|
|||
|
|
}
|
|||
|
|
function portableStateForArchive(state){
|
|||
|
|
const value=stateForStorage(state);delete value.rev;delete value.revAuthor;return value;
|
|||
|
|
}
|
|||
|
|
async function loadV2ArchiveBoardPage(epoch,afterNumber=-1,limit=32){
|
|||
|
|
const db=await openWorldDb(),tx=db.transaction('boardIndex','readonly'),done=transactionDone(tx),
|
|||
|
|
range=IDBKeyRange.bound([epoch,Math.max(0,afterNumber+1)],[epoch,Number.MAX_SAFE_INTEGER]),
|
|||
|
|
indexes=await requestValue(tx.objectStore('boardIndex').index('epochNumber').getAll(range,limit));await done;
|
|||
|
|
if(!indexes.length)return[];
|
|||
|
|
const detailsTx=db.transaction(['boardPuzzles','boardStates'],'readonly'),detailsDone=transactionDone(detailsTx),
|
|||
|
|
puzzleStore=detailsTx.objectStore('boardPuzzles'),stateStore=detailsTx.objectStore('boardStates'),
|
|||
|
|
[puzzles,states]=await Promise.all([Promise.all(indexes.map(index=>requestValue(puzzleStore.get([epoch,index.id])))),Promise.all(indexes.map(index=>requestValue(stateStore.get([epoch,index.id]))))]);await detailsDone;
|
|||
|
|
return indexes.map((index,position)=>{
|
|||
|
|
const puzzle=puzzles[position],state=states[position];if(!puzzle||!state||puzzle.metaRev!==index.metaRev||state.stateRev!==index.stateRev)throw new Error(`Stored board revisions do not match for ${index.id}.`);
|
|||
|
|
const meta={id:index.id,x:index.x,y:index.y,chunks:index.chunks,level:index.level,targetLevel:index.targetLevel,seed:index.seed,axis:index.axis,entrySide:index.entrySide||null,sealedSides:[...(index.sealedSides||[])],puzzle:puzzle.puzzle,generatorVersion:puzzle.generatorVersion||GENERATOR_VERSION,rev:puzzle.metaRev,revAuthor:puzzle.revAuthor||index.metaRevAuthor||''};
|
|||
|
|
return{id:index.id,number:index.number,meta:portableMetaForArchive(meta),state:portableStateForArchive(state.value)};
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
async function* portableBoardsForExport(epoch){
|
|||
|
|
if(activeStorageFormat===FIELD_STORAGE_FORMAT){
|
|||
|
|
let after=-1;
|
|||
|
|
while(true){const page=await loadV2ArchiveBoardPage(epoch,after);if(!page.length)break;for(const board of page){after=board.number;yield{id:board.id,meta:board.meta,state:board.state}}}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
for(const id of Object.keys(data.metas).sort((a,b)=>(Number(a.slice(1))||0)-(Number(b.slice(1))||0)))yield{id,meta:portableMetaForArchive(data.metas[id]),state:portableStateForArchive(data.states[id]||normalizeState(null))};
|
|||
|
|
}
|
|||
|
|
async function activeWorldArchiveHeader(epoch){
|
|||
|
|
const db=await openWorldDb(),tx=db.transaction(['control','worlds'],'readonly'),done=transactionDone(tx),controlStore=tx.objectStore('control'),worldsStore=tx.objectStore('worlds'),[control,world]=await Promise.all([requestValue(controlStore.get('active')),requestValue(worldsStore.get(epoch))]);await done;
|
|||
|
|
if(!control||control.activeFormat!==FIELD_STORAGE_FORMAT||control.activeEpoch!==epoch||!world||world.status!=='active')throw staleWorldEpochError();return world;
|
|||
|
|
}
|
|||
|
|
async function downloadSaveExport(destination){
|
|||
|
|
const controller=new AbortController();activeArchiveController=controller;setFieldArchiveBusy(true,'\u30d5\u30a3\u30fc\u30eb\u30c9\u3092\u66f8\u304d\u51fa\u3057\u3066\u3044\u307e\u3059\u3002');
|
|||
|
|
try{
|
|||
|
|
if(!fieldIndexComplete)await completeV2IndexScan();
|
|||
|
|
if(!await flushSave())throw new Error('The field could not be checkpointed before export.');
|
|||
|
|
return await withWorldMutationLock(async()=>{
|
|||
|
|
if(hasPendingPersistence())await persistDirtyToDb({skipCloud:true});
|
|||
|
|
const epoch=data.worldEpoch,world=await activeWorldArchiveHeader(epoch),boardCount=world.boardCount||Object.keys(data.metas).length,portableGlobal=portableGlobalForArchive(),compression=typeof CompressionStream==='function'?'gzip':'identity',estimatedRawBytes=Math.max(boardCount*1024,(world.approximateBytes||0)+JSON.stringify(portableGlobal).length+boardCount*256),
|
|||
|
|
manifest={saveSchema:SAVE_SCHEMA,gameplayVersion:GAMEPLAY_DATA_VERSION,worldGeneration:WORLD_GENERATION,appVersion:APP_VERSION,generatorVersion:GENERATOR_VERSION,exportedAt:new Date().toISOString(),boardCount,estimatedRawBytes,encoding:'ndjson',compression},
|
|||
|
|
result=await FieldPersistence.writeArchive({writable:destination.writable,manifest,globalState:portableGlobal,boards:portableBoardsForExport(epoch),signal:controller.signal,onProgress:progress=>setSaveStatus('saving',`${progress.boardsDone}/${progress.boardsTotal}`)});
|
|||
|
|
await destination.finish();setSaveStatus('saved','\u66f8\u304d\u51fa\u3057\u5b8c\u4e86');toast('\u30d5\u30a3\u30fc\u30eb\u30c9\u3092\u66f8\u304d\u51fa\u3057\u307e\u3057\u305f\u3002');return result;
|
|||
|
|
});
|
|||
|
|
}catch(error){
|
|||
|
|
await destination.abort(error);if(error?.name==='AbortError')toast('\u66f8\u304d\u51fa\u3057\u3092\u30ad\u30e3\u30f3\u30bb\u30eb\u3057\u307e\u3057\u305f\u3002');else throw error;
|
|||
|
|
}finally{activeArchiveController=null;setFieldArchiveBusy(false);hideStatus()}
|
|||
|
|
}
|
|||
|
|
exportBtn.onclick=async()=>{
|
|||
|
|
const filename=`bend-field-save-${new Date().toISOString().slice(0,10)}.bfsave`;
|
|||
|
|
try{
|
|||
|
|
const destination=await FieldPersistence.createArchiveDestination(filename,Math.max(1024,Object.keys(data.metas).length*1024));
|
|||
|
|
await downloadSaveExport(destination);
|
|||
|
|
}catch(error){if(error?.name!=='AbortError'){showStatus(`\u66f8\u304d\u51fa\u3057\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002 ${error?.message||error}`,{retry:false,fresh:false});toast('\u66f8\u304d\u51fa\u3057\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002')}}
|
|||
|
|
};
|
|||
|
|
async function beginWorldReplacement(reason){
|
|||
|
|
if(!await flushSave())throw new Error('The current field could not be checkpointed.');
|
|||
|
|
lifecyclePersistenceSuppressed=true;clearTimeout(saveTimer);saveTimer=null;cancelMirrorCheckpoint(true);await persistQueue;
|
|||
|
|
if(activeStorageFormat!==FIELD_STORAGE_FORMAT)await preserveRecoveryDurably(JSON.stringify(compactSnapshot()),reason);
|
|||
|
|
return createWorldEpoch();
|
|||
|
|
}
|
|||
|
|
document.querySelector('#freshBtn').onclick=async()=>{
|
|||
|
|
if(!confirm('\u73fe\u5728\u306e\u9032\u884c\u3092\u30d0\u30c3\u30af\u30a2\u30c3\u30d7\u3057\u3001\u6700\u521d\u304b\u3089\u59cb\u3081\u307e\u3059\u304b\uff1f'))return;
|
|||
|
|
try{const newEpoch=await beginWorldReplacement('User started fresh');await clearDatabaseWorld(newEpoch);clearCompactMirror();clearRecoveryJournalKeys();safeLocalRemove(storageRevisionKey);announceWorldReplacement(newEpoch);location.reload()}
|
|||
|
|
catch(error){lifecyclePersistenceSuppressed=false;showStatus(`\u65b0\u3057\u3044\u30d5\u30a3\u30fc\u30eb\u30c9\u3092\u958b\u59cb\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002 ${error?.message||error}`,{retry:true,fresh:true})}
|
|||
|
|
};
|
|||
|
|
function validateImportedStateStrict(meta,raw){
|
|||
|
|
if(!isPlainObject(raw)||!Array.isArray(raw.paths)||raw.paths.length>MAX_PATHS_PER_BOARD)throw new Error(`Invalid board state: ${meta.id}`);
|
|||
|
|
const state=normalizeState(raw);if(state.paths.length!==raw.paths.length)throw new Error(`Invalid path record: ${meta.id}`);
|
|||
|
|
const puzzle=meta.puzzle,validCells=cellSet(puzzle),crossingSet=new Set(crossingKeys(puzzle)),usedCells=new Map(),usedGates=new Set();
|
|||
|
|
for(const path of state.paths){
|
|||
|
|
let valid=path.startGate>=0&&path.startGate<puzzle.g.length&&path.endGate!==path.startGate&&(!path.detachedStart||path.endGate==null);
|
|||
|
|
if(valid&&path.endGate!=null)valid=path.endGate>=0&&path.endGate<puzzle.g.length;
|
|||
|
|
if(valid){const start=puzzle.g[path.startGate];valid=path.detachedStart||sameCell(path.cells[0],start);if(path.endGate!=null)valid=valid&&sameCell(path.cells[path.cells.length-1],puzzle.g[path.endGate])}
|
|||
|
|
const ownCells=new Set();
|
|||
|
|
if(valid)for(let index=0;index<path.cells.length;index++){
|
|||
|
|
const cell=path.cells[index],key=ckey(...cell),owners=usedCells.get(key)||0;
|
|||
|
|
if(!validCells.has(key)||ownCells.has(key)||(owners>0&&!crossingSet.has(key))||owners>=2||(index>0&&!pathCellsAdjacent(puzzle,path.cells[index-1],cell))){valid=false;break}
|
|||
|
|
ownCells.add(key);
|
|||
|
|
}
|
|||
|
|
if(valid&&(!path.detachedStart&&usedGates.has(path.startGate)||path.endGate!=null&&usedGates.has(path.endGate)||path.openGate!=null&&usedGates.has(path.openGate)))valid=false;
|
|||
|
|
if(!valid)throw new Error(`Invalid path geometry: ${meta.id}`);
|
|||
|
|
for(const key of ownCells)usedCells.set(key,(usedCells.get(key)||0)+1);
|
|||
|
|
if(!path.detachedStart)usedGates.add(path.startGate);if(path.endGate!=null)usedGates.add(path.endGate);if(path.openGate!=null)usedGates.add(path.openGate);
|
|||
|
|
}
|
|||
|
|
const solved=isSolved(state,puzzle);if(state.solved!==solved)throw new Error(`Solved state does not match the puzzle: ${meta.id}`);
|
|||
|
|
if(!solved&&(state.solvedBy||state.scoreAwarded||state.store))throw new Error(`Unsolved board contains solved-only data: ${meta.id}`);
|
|||
|
|
return state;
|
|||
|
|
}
|
|||
|
|
async function deleteV2Epoch(epoch){
|
|||
|
|
if(!idbAvailable||!validWorldEpoch(epoch))return false;
|
|||
|
|
const db=await openWorldDb(),stores=['boardIndex','boardPuzzles','boardStates','tombstonesV2','recoveryV2','outboxV2'],tx=db.transaction(['worlds',...stores],'readwrite'),done=transactionDone(tx),range=epochKeyRange(epoch);
|
|||
|
|
tx.objectStore('worlds').delete(epoch);for(const name of stores)tx.objectStore(name).delete(range);await done;return true;
|
|||
|
|
}
|
|||
|
|
async function verifyActiveWorldActivation(){
|
|||
|
|
if(!idbAvailable||activeStorageFormat!==FIELD_STORAGE_FORMAT)return false;
|
|||
|
|
const db=await openWorldDb(),control=await readActiveWorldControl(db);
|
|||
|
|
if(!control||control.activationVerified!==false||control.activeEpoch!==data.worldEpoch)return false;
|
|||
|
|
await validateActiveWorldReadiness(control.activeEpoch);
|
|||
|
|
const tx=db.transaction(['control','worlds'],'readwrite'),done=transactionDone(tx),controlStore=tx.objectStore('control'),worldsStore=tx.objectStore('worlds'),
|
|||
|
|
[fresh,active,previous]=await Promise.all([requestValue(controlStore.get('active')),requestValue(worldsStore.get(control.activeEpoch)),control.previousEpoch?requestValue(worldsStore.get(control.previousEpoch)):Promise.resolve(null)]);
|
|||
|
|
if(!fresh||fresh.activeEpoch!==control.activeEpoch||fresh.activationVerified!==false||!active){try{tx.abort()}catch(_){}await done.catch(()=>{});throw staleWorldEpochError()}
|
|||
|
|
if(previous){previous.status='garbage';previous.progress={phase:'cleanup',store:'boardIndex',rows:0};worldsStore.put(previous)}
|
|||
|
|
active.status='active';worldsStore.put(active);
|
|||
|
|
const verified={...fresh,previousEpoch:undefined,activationVerified:true};controlStore.put(verified);await done;startupWorldControl=verified;return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function stableJson(value){if(Array.isArray(value))return`[${value.map(stableJson).join(',')}]`;if(value&&typeof value==='object')return`{${Object.keys(value).sort().map(key=>`${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;return JSON.stringify(value)}
|
|||
|
|
function boardIndexMatchesDetails(index,meta,state,epoch){
|
|||
|
|
const expected=summarizeBoardV2(meta,state,epoch),keys=['epoch','id','number','x','y','chunks','level','targetLevel','seed','axis','entrySide','metaRev','stateRev','revAuthor','solved','expanded','scoreAwarded','hasProgress','specialFlags','shop'];
|
|||
|
|
return keys.every(key=>stableJson(index?.[key])===stableJson(expected[key]));
|
|||
|
|
}
|
|||
|
|
async function validateStagedWorldV2(epoch,{expectedBoardCount=null,strict=true,allowStaging=false}={}){
|
|||
|
|
if(!validWorldEpoch(epoch))throw new Error('The staged field epoch is invalid.');
|
|||
|
|
const db=await openWorldDb();let after=-1,boardCount=0,solvedCount=0,boardScore=0,maxNumber=0,approximateBytes=0,bounds=null,originOwnedByB0=false;const occupied=new Set();
|
|||
|
|
while(true){
|
|||
|
|
const tx=db.transaction(['boardIndex','boardPuzzles','boardStates'],'readonly'),done=transactionDone(tx),indexStore=tx.objectStore('boardIndex'),puzzleStore=tx.objectStore('boardPuzzles'),stateStore=tx.objectStore('boardStates'),
|
|||
|
|
indexes=await requestValue(indexStore.index('epochNumber').getAll(IDBKeyRange.bound([epoch,Math.max(0,after+1)],[epoch,Number.MAX_SAFE_INTEGER]),512));
|
|||
|
|
if(!indexes.length){await done;break}
|
|||
|
|
const [puzzles,states]=await Promise.all([Promise.all(indexes.map(index=>requestValue(puzzleStore.get([epoch,index.id])))),Promise.all(indexes.map(index=>requestValue(stateStore.get([epoch,index.id]))))]);await done;
|
|||
|
|
for(let position=0;position<indexes.length;position++){
|
|||
|
|
const index=indexes[position],puzzle=puzzles[position],stateRow=states[position],number=Number(index.id?.slice(1));
|
|||
|
|
if(!Number.isSafeInteger(number)||number<0||number<=after||index.number!==number)throw new Error(`The staged board order is invalid: ${index.id}`);
|
|||
|
|
if(!puzzle||!stateRow||puzzle.metaRev!==index.metaRev||stateRow.stateRev!==index.stateRev)throw new Error(`The staged board revisions do not match: ${index.id}`);
|
|||
|
|
const meta=normalizeMeta(index.id,{id:index.id,x:index.x,y:index.y,chunks:index.chunks,level:index.level,targetLevel:index.targetLevel,seed:index.seed,axis:index.axis,entrySide:index.entrySide||null,sealedSides:index.sealedSides||[],puzzle:puzzle.puzzle,generatorVersion:puzzle.generatorVersion||GENERATOR_VERSION,rev:puzzle.metaRev,revAuthor:puzzle.revAuthor||index.metaRevAuthor||''});
|
|||
|
|
if(!meta)throw new Error(`The staged puzzle is invalid: ${index.id}`);
|
|||
|
|
const state=strict?validateImportedStateStrict(meta,stateRow.value):normalizeState(stateRow.value);
|
|||
|
|
if(state.rev!==index.stateRev||!boardIndexMatchesDetails(index,meta,state,epoch))throw new Error(`The staged summary does not match its board details: ${index.id}`);
|
|||
|
|
for(const[dx,dy]of meta.chunks){const x=meta.x+dx,y=meta.y+dy,key=key2(x,y);if(occupied.has(key))throw new Error(`The staged field contains overlapping boards: ${index.id}`);occupied.add(key);if(index.id==='B0'&&x===0&&y===0)originOwnedByB0=true;bounds=bounds?{minX:Math.min(bounds.minX,x),minY:Math.min(bounds.minY,y),maxX:Math.max(bounds.maxX,x+1),maxY:Math.max(bounds.maxY,y+1)}:{minX:x,minY:y,maxX:x+1,maxY:y+1}}
|
|||
|
|
if(boardCount===0&&index.id!=='B0'||index.id==='B0'&&boardCount!==0)throw new Error('B0 is not the first staged board.');
|
|||
|
|
boardCount++;after=number;maxNumber=Math.max(maxNumber,number);if(state.solved){solvedCount++;boardScore=Math.min(MAX_SCORE,boardScore+state.scoreAwarded)}
|
|||
|
|
approximateBytes+=JSON.stringify(index).length+JSON.stringify(puzzle).length+JSON.stringify(stateRow).length;if(position>0&&position%32===0)await new Promise(resolve=>setTimeout(resolve,0));
|
|||
|
|
}
|
|||
|
|
await new Promise(resolve=>setTimeout(resolve,0));
|
|||
|
|
}
|
|||
|
|
if(boardCount<1||!occupied.size||!bounds)throw new Error('The staged field is empty.');
|
|||
|
|
if(!originOwnedByB0||after<0)throw new Error('B0 does not own the staged field origin.');
|
|||
|
|
const countTx=db.transaction(['worlds','boardIndex','boardPuzzles','boardStates'],'readonly'),countDone=transactionDone(countTx),worldsStore=countTx.objectStore('worlds'),
|
|||
|
|
[world,indexCount,puzzleCount,stateCount]=await Promise.all([requestValue(worldsStore.get(epoch)),requestValue(countTx.objectStore('boardIndex').index('epoch').count(IDBKeyRange.only(epoch))),requestValue(countTx.objectStore('boardPuzzles').index('epoch').count(IDBKeyRange.only(epoch))),requestValue(countTx.objectStore('boardStates').index('epoch').count(IDBKeyRange.only(epoch)))]);await countDone;
|
|||
|
|
const allowedStatus=allowStaging?world?.status==='staging'||world?.status==='ready':world?.status==='ready';
|
|||
|
|
if(!world||!allowedStatus||indexCount!==boardCount||puzzleCount!==boardCount||stateCount!==boardCount)throw new Error('The staged field row counts do not match.');
|
|||
|
|
if(expectedBoardCount!=null&&boardCount!==expectedBoardCount)throw new Error('The staged field board count changed during validation.');
|
|||
|
|
const expectedScore=Math.min(MAX_SCORE,boardScore+bonusEventTotal(world.global?.bonusEvents||{}));
|
|||
|
|
if((world.global?.nextId||0)!==maxNumber+1||(world.global?.solved||0)!==solvedCount||(world.global?.score||0)!==expectedScore)throw new Error('The staged global aggregates do not match the board rows.');
|
|||
|
|
return{boardCount,solvedCount,score:expectedScore,boardScore,nextId:maxNumber+1,bounds,approximateBytes};
|
|||
|
|
}
|
|||
|
|
async function validateActiveWorldReadiness(epoch){
|
|||
|
|
const db=await openWorldDb(),tx=db.transaction(['control','worlds','boardIndex','boardPuzzles','boardStates'],'readonly'),done=transactionDone(tx),controlStore=tx.objectStore('control'),worldsStore=tx.objectStore('worlds'),indexStore=tx.objectStore('boardIndex'),
|
|||
|
|
[control,world,indexes,b0Index,b0Puzzle,b0State]=await Promise.all([requestValue(controlStore.get('active')),requestValue(worldsStore.get(epoch)),requestValue(indexStore.index('epochNumber').getAll(IDBKeyRange.bound([epoch,0],[epoch,Number.MAX_SAFE_INTEGER]),512)),requestValue(indexStore.get([epoch,'B0'])),requestValue(tx.objectStore('boardPuzzles').get([epoch,'B0'])),requestValue(tx.objectStore('boardStates').get([epoch,'B0']))]);await done;
|
|||
|
|
if(!control||control.activeEpoch!==epoch||control.activeFormat!==FIELD_STORAGE_FORMAT||!world||world.status!=='active'||!indexes.length||indexes[0].id!=='B0')throw new Error('The activated field header or first index page is invalid.');
|
|||
|
|
const occupied=new Set();for(const index of indexes){if(!/^B(?:0|[1-9]\d*)$/.test(index.id)||!validChunkShape(index.chunks))throw new Error(`The activated field index is invalid: ${index.id}`);for(const[dx,dy]of index.chunks){const key=key2(index.x+dx,index.y+dy);if(occupied.has(key))throw new Error('The activated field first index page overlaps.');occupied.add(key)}}
|
|||
|
|
if(!b0Index||!b0Puzzle||!b0State||b0Index.metaRev!==b0Puzzle.metaRev||b0Index.stateRev!==b0State.stateRev)throw new Error('The activated field failed its B0 revision check.');
|
|||
|
|
const meta=normalizeMeta('B0',{id:'B0',x:b0Index.x,y:b0Index.y,chunks:b0Index.chunks,level:b0Index.level,targetLevel:b0Index.targetLevel,seed:b0Index.seed,axis:b0Index.axis,entrySide:b0Index.entrySide||null,sealedSides:b0Index.sealedSides||[],puzzle:b0Puzzle.puzzle,generatorVersion:b0Puzzle.generatorVersion||GENERATOR_VERSION,rev:b0Puzzle.metaRev,revAuthor:b0Puzzle.revAuthor||b0Index.metaRevAuthor||''});
|
|||
|
|
if(!meta||!meta.chunks.some(([dx,dy])=>meta.x+dx===0&&meta.y+dy===0))throw new Error('The activated field contains an invalid B0 puzzle or origin.');validateImportedStateStrict(meta,b0State.value);return true;
|
|||
|
|
}
|
|||
|
|
async function deleteV2EpochBatch(epoch,limit=GC_BATCH_ROWS){
|
|||
|
|
const stores=['boardIndex','boardPuzzles','boardStates','tombstonesV2','recoveryV2','outboxV2'],db=await openWorldDb(),worldTx=db.transaction('worlds','readonly'),worldDone=transactionDone(worldTx),world=await requestValue(worldTx.objectStore('worlds').get(epoch));await worldDone;if(!world)return{done:true,rows:0};
|
|||
|
|
let storeIndex=Math.max(0,stores.indexOf(world.progress?.store));if(storeIndex<0)storeIndex=0;
|
|||
|
|
for(;storeIndex<stores.length;storeIndex++){
|
|||
|
|
const name=stores[storeIndex],tx=db.transaction([name,'worlds'],'readwrite'),done=transactionDone(tx),store=tx.objectStore(name),keys=await requestValue(store.getAllKeys(epochKeyRange(epoch),limit));
|
|||
|
|
for(const key of keys)store.delete(key);
|
|||
|
|
const next={...world,progress:{phase:'cleanup',store:name,lastKey:keys[keys.length-1]||world.progress?.lastKey||null,rows:(world.progress?.rows||0)+keys.length}};tx.objectStore('worlds').put(next);await done;
|
|||
|
|
if(keys.length)return{done:false,rows:keys.length};world.progress=next.progress;
|
|||
|
|
}
|
|||
|
|
const finalTx=db.transaction('worlds','readwrite'),finalDone=transactionDone(finalTx);finalTx.objectStore('worlds').delete(epoch);await finalDone;return{done:true,rows:0};
|
|||
|
|
}
|
|||
|
|
async function collectV2Garbage(){
|
|||
|
|
if(!idbAvailable||activeStorageFormat!==FIELD_STORAGE_FORMAT)return 0;
|
|||
|
|
const db=await openWorldDb(),control=await readActiveWorldControl(db),tx=db.transaction(['worlds','recoveryV2'],'readonly'),done=transactionDone(tx),worlds=await requestValue(tx.objectStore('worlds').getAll()),pins=await requestValue(tx.objectStore('recoveryV2').getAll());await done;
|
|||
|
|
const pinned=new Set(pins.filter(row=>String(row?.key||'').startsWith('pin:')&&validWorldEpoch(row?.value?.epoch||row?.value?.worldEpoch)).map(row=>row.value.epoch||row.value.worldEpoch)),cutoff=trustedNow()-24*60*60*1000,
|
|||
|
|
target=worlds.find(world=>world.epoch!==control?.activeEpoch&&world.epoch!==control?.previousEpoch&&!pinned.has(world.epoch)&&(world.status==='garbage'||(world.status==='staging'||world.status==='ready')&&(world.createdAt||0)<cutoff));
|
|||
|
|
if(FieldPersistence.cleanupTemporaryExports)await FieldPersistence.cleanupTemporaryExports({olderThanMs:24*60*60*1000}).catch(()=>{});
|
|||
|
|
if(!target)return 0;const result=await deleteV2EpochBatch(target.epoch,GC_BATCH_ROWS);if(result.rows)perfCount('fieldEpochRowsCollected',result.rows);if(result.done)perfCount('fieldEpochsCollected');
|
|||
|
|
setTimeout(()=>{void collectV2Garbage().catch(error=>console.warn('BEND FIELD: epoch cleanup deferred',error))},0);return result.rows;
|
|||
|
|
}
|
|||
|
|
async function writeStagedBoardBatch(epoch,batch){
|
|||
|
|
if(!batch.length)return;
|
|||
|
|
const db=await openWorldDb(),tx=db.transaction(['boardIndex','boardPuzzles','boardStates'],'readwrite'),done=transactionDone(tx),
|
|||
|
|
indexStore=tx.objectStore('boardIndex'),puzzleStore=tx.objectStore('boardPuzzles'),stateStore=tx.objectStore('boardStates');
|
|||
|
|
for(const{meta,state}of batch){indexStore.put(summarizeBoardV2(meta,state,epoch));puzzleStore.put(puzzleRecordV2(meta,epoch));stateStore.put(stateRecordV2(meta.id,state,epoch))}
|
|||
|
|
await done;
|
|||
|
|
}
|
|||
|
|
function importedGlobalFromPortable(raw,epoch,{nextId,solved,boardScore}){
|
|||
|
|
const result=defaultData();result.worldEpoch=epoch;result.gameplayVersion=GAMEPLAY_DATA_VERSION;
|
|||
|
|
result.bonusEvents=normalizeBonusEvents(raw?.bonusEvents);result.bonusScore=bonusEventTotal(result.bonusEvents);result.bonusScoreVersion=SCORE_VERSION;
|
|||
|
|
result.specialMechanicsSeen=[...new Set(raw?.specialMechanicsSeen||[])].filter(type=>['warp','lock','crossing'].includes(type)).sort();
|
|||
|
|
result.clockFloor=Math.max(0,Number(raw?.clockFloor)||0);result.lastSolveAt=Math.max(0,Number(raw?.lastSolveAt)||0);
|
|||
|
|
result.timeAttack=normalizeTimeAttackRun(raw?.timeAttack);result.timeAttackCooldowns=normalizeTimeAttackCooldowns(raw?.timeAttackCooldowns);result.lastTimeAttack=normalizeTimeAttackResult(raw?.lastTimeAttack);
|
|||
|
|
result.timeAttackSuggestionsDisabled=raw?.timeAttackSuggestionsDisabled===true;result.cursorStyle=typeof raw?.cursorStyle==='string'?raw.cursorStyle.slice(0,32):'default';result.scoreLensEnabled=raw?.scoreLensEnabled===true;
|
|||
|
|
result.nextId=nextId;result.solved=solved;result.score=Math.min(MAX_SCORE,boardScore+result.bonusScore);result.globalRev=nextRevision();result.globalRevAuthor=sessionId;
|
|||
|
|
result.cloudProfile=null;result.cloudRevision=0;result.cloudSyncPaused=true;result.cloudPending=normalizeCloudPending(null);result.updatedAt=trustedNow();return result;
|
|||
|
|
}
|
|||
|
|
async function activeWorldExpectation(){
|
|||
|
|
const db=await openWorldDb(),control=await readActiveWorldControl(db);
|
|||
|
|
if(!control||!validWorldEpoch(control.activeEpoch))throw new Error('The active field control record is missing.');
|
|||
|
|
const tx=db.transaction('worlds','readonly'),done=transactionDone(tx),world=await requestValue(tx.objectStore('worlds').get(control.activeEpoch));await done;
|
|||
|
|
return{epoch:control.activeEpoch,globalRev:world?.global?.globalRev||0,format:control.activeFormat};
|
|||
|
|
}
|
|||
|
|
async function preflightArchiveStorage(estimatedRawBytes){
|
|||
|
|
if(!navigator.storage?.estimate)return true;
|
|||
|
|
const estimate=await navigator.storage.estimate(),required=Math.max(32*1024*1024,Math.max(0,Number(estimatedRawBytes)||0)*2+32*1024*1024),
|
|||
|
|
available=Math.max(0,(estimate.quota||0)-(estimate.usage||0));
|
|||
|
|
if(estimate.quota&&available<required){const error=new Error('There is not enough browser storage to stage this field safely.');error.code='QUOTA_PREFLIGHT';throw error}
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
async function verifyStagedStorageEstimate(){
|
|||
|
|
if(!navigator.storage?.estimate)return true;
|
|||
|
|
const estimate=await navigator.storage.estimate(),quota=Number(estimate.quota)||0,usage=Number(estimate.usage)||0;
|
|||
|
|
if(quota&"a-usage<1024*1024){const error=new Error('There is not enough browser storage to activate the staged field safely.');error.code='QUOTA_POSTFLIGHT';throw error}
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function assertCurrentArchive(manifest){
|
|||
|
|
if(manifest?.saveSchema!==SAVE_SCHEMA||manifest?.gameplayVersion!==GAMEPLAY_DATA_VERSION||manifest?.worldGeneration!==WORLD_GENERATION)throw new Error('この版で作成したバックアップだけを読み込めます。');
|
|||
|
|
return manifest;
|
|||
|
|
}
|
|||
|
|
async function stageArchiveV2(file,controller,expected){
|
|||
|
|
if(!idbAvailable)throw new Error('IndexedDB is required to restore a field.');
|
|||
|
|
const epoch=createWorldEpoch(),db=await openWorldDb(),occupied=new Set(),batch=[],batchLimit=256;
|
|||
|
|
let batchBytes=0,globalRaw=null,boardCount=0,solved=0,boardScore=0,maxNumber=0,approximateBytes=0,manifest=null,minX=0,minY=0,maxX=1,maxY=1,boundsReady=false;
|
|||
|
|
{const tx=db.transaction('worlds','readwrite'),done=transactionDone(tx);tx.objectStore('worlds').put({epoch,status:'staging',schema:SAVE_SCHEMA,worldGeneration:WORLD_GENERATION,global:null,boardCount:0,solvedCount:0,score:0,bounds:{minX:0,minY:0,maxX:1,maxY:1},approximateBytes:0,createdAt:trustedNow(),source:{kind:'import'},progress:{phase:'validate',rows:0}});await done}
|
|||
|
|
const flush=async()=>{if(!batch.length)return;await writeStagedBoardBatch(epoch,batch);const tx=db.transaction('worlds','readwrite'),done=transactionDone(tx),row=await requestValue(tx.objectStore('worlds').get(epoch));tx.objectStore('worlds').put({...row,progress:{phase:'stage',lastKey:batch[batch.length-1].meta.id,rows:boardCount}});await done;batch.length=0;batchBytes=0};
|
|||
|
|
try{
|
|||
|
|
const result=await FieldPersistence.readArchive(file,{
|
|||
|
|
signal:controller.signal,onProgress:progress=>{setSaveStatus('saving',`${progress.boardsDone}/${progress.boardsTotal}`);showStatus(`フィールドを検証中 ${progress.boardsDone}/${progress.boardsTotal}`,{retry:false,fresh:false})},
|
|||
|
|
onManifest:async value=>{manifest=assertCurrentArchive(value);if(value.boardCount<1||value.boardCount>MAX_BOARDS)throw new Error('The archive board count is outside the supported range.');await preflightArchiveStorage(value.estimatedRawBytes)},
|
|||
|
|
onGlobal:value=>{globalRaw=value},
|
|||
|
|
onBoard:async record=>{
|
|||
|
|
if(!isPlainObject(record.meta)||!isPlainObject(record.state)||record.meta.id&&record.meta.id!==record.id)throw new Error(`Invalid board record: ${record.id}`);
|
|||
|
|
const meta=normalizeMeta(record.id,{...record.meta,id:record.id,rev:0,revAuthor:''});if(!meta)throw new Error(`Invalid puzzle metadata: ${record.id}`);
|
|||
|
|
if(stableJson(portableMetaForArchive(meta))!==stableJson(record.meta))throw new Error(`The board metadata is not canonical: ${record.id}`);
|
|||
|
|
for(const[dx,dy]of meta.chunks){const x=meta.x+dx,y=meta.y+dy,key=key2(x,y);if(occupied.has(key))throw new Error(`Overlapping board geometry: ${record.id}`);occupied.add(key);if(!boundsReady){minX=x;minY=y;maxX=x+1;maxY=y+1;boundsReady=true}else{minX=Math.min(minX,x);minY=Math.min(minY,y);maxX=Math.max(maxX,x+1);maxY=Math.max(maxY,y+1)}}
|
|||
|
|
const state=validateImportedStateStrict(meta,{...record.state,rev:0,revAuthor:''});if(stableJson(portableStateForArchive(state))!==stableJson(record.state))throw new Error(`The board state is not canonical: ${record.id}`);
|
|||
|
|
const revision=Math.min(Number.MAX_SAFE_INTEGER-2,Date.now()*1000+boardCount*2);meta.rev=revision;meta.revAuthor=sessionId;state.rev=revision+1;state.revAuthor=sessionId;
|
|||
|
|
boardCount++;maxNumber=Math.max(maxNumber,Number(record.id.slice(1))||0);if(state.solved){solved++;boardScore=Math.min(MAX_SCORE,boardScore+state.scoreAwarded)}
|
|||
|
|
const bytes=JSON.stringify(record).length;approximateBytes+=bytes;batchBytes+=bytes;batch.push({meta,state});if(batch.length>=batchLimit||batchBytes>=4*1024*1024)await flush();else if(boardCount%32===0)await new Promise(resolve=>setTimeout(resolve,0));
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
await flush();if(!manifest||!globalRaw||boardCount!==manifest.boardCount||!occupied.size)throw new Error('The archive did not produce a complete field.');
|
|||
|
|
const imported=importedGlobalFromPortable(globalRaw,epoch,{nextId:maxNumber+1,solved,boardScore});if(stableJson(portableGlobalForArchive(imported))!==stableJson(globalRaw))throw new Error('The archive global record is not canonical.');
|
|||
|
|
const provisional={epoch,status:'staging',schema:SAVE_SCHEMA,worldGeneration:WORLD_GENERATION,global:globalForStorage(imported),boardCount,solvedCount:solved,score:imported.score,bounds:{minX,minY,maxX,maxY},approximateBytes:result.decodedBytes||approximateBytes,createdAt:trustedNow(),source:{kind:'import',archiveCrc32:result.footer.crc32},progress:{phase:'verify',rows:boardCount}},
|
|||
|
|
tx=db.transaction('worlds','readwrite'),done=transactionDone(tx);tx.objectStore('worlds').put(provisional);await done;
|
|||
|
|
const verified=await validateStagedWorldV2(epoch,{expectedBoardCount:manifest.boardCount,strict:true,allowStaging:true});await verifyStagedStorageEstimate();const world={...provisional,status:'ready',boardCount:verified.boardCount,solvedCount:verified.solvedCount,score:verified.score,bounds:verified.bounds,approximateBytes:verified.approximateBytes,progress:null};
|
|||
|
|
const readyTx=db.transaction('worlds','readwrite'),readyDone=transactionDone(readyTx);readyTx.objectStore('worlds').put(world);await readyDone;return{epoch,expected,world,manifest};
|
|||
|
|
}catch(error){await deleteV2Epoch(epoch).catch(()=>{});throw error}
|
|||
|
|
}
|
|||
|
|
async function stageRecoverySnapshotV2(snapshot,expected,controller){
|
|||
|
|
const epoch=createWorldEpoch(),ids=Object.keys(snapshot.metas).sort((a,b)=>(Number(a.slice(1))||0)-(Number(b.slice(1))||0));await preflightArchiveStorage(Math.max(1,ids.length)*1024);
|
|||
|
|
const db=await openWorldDb(),portableGlobal=portableGlobalForArchive(snapshot),occupied=new Set();let solved=0,boardScore=0,maxNumber=0,approximateBytes=0;
|
|||
|
|
try{
|
|||
|
|
const initialTx=db.transaction('worlds','readwrite'),initialDone=transactionDone(initialTx);initialTx.objectStore('worlds').put({epoch,status:'staging',schema:SAVE_SCHEMA,worldGeneration:WORLD_GENERATION,global:null,boardCount:0,createdAt:trustedNow(),source:{kind:'import'},progress:{phase:'stage',rows:0}});await initialDone;
|
|||
|
|
for(let offset=0;offset<ids.length;offset+=64){
|
|||
|
|
if(controller.signal.aborted)throw controller.signal.reason||new DOMException('Canceled','AbortError');
|
|||
|
|
const batch=ids.slice(offset,offset+64).map((id,position)=>{const meta=normalizeMeta(id,{...metaForStorage(snapshot.metas[id]),rev:0,revAuthor:''});if(!meta)throw new Error(`Invalid recovery board: ${id}`);for(const[dx,dy]of meta.chunks){const key=key2(meta.x+dx,meta.y+dy);if(occupied.has(key))throw new Error(`Overlapping recovery board: ${id}`);occupied.add(key)}const state=normalizeState(stateForStorage(snapshot.states[id]||normalizeState(null))),revision=Math.min(Number.MAX_SAFE_INTEGER-2,Date.now()*1000+(offset+position)*2);meta.rev=revision;meta.revAuthor=sessionId;state.rev=revision+1;state.revAuthor=sessionId;if(state.solved){solved++;boardScore=Math.min(MAX_SCORE,boardScore+state.scoreAwarded)}maxNumber=Math.max(maxNumber,Number(id.slice(1))||0);approximateBytes+=JSON.stringify(meta).length+JSON.stringify(state).length;return{meta,state}});
|
|||
|
|
await writeStagedBoardBatch(epoch,batch);const progressTx=db.transaction('worlds','readwrite'),progressDone=transactionDone(progressTx),row=await requestValue(progressTx.objectStore('worlds').get(epoch));progressTx.objectStore('worlds').put({...row,progress:{phase:'stage',lastKey:batch[batch.length-1]?.meta.id||null,rows:Math.min(ids.length,offset+batch.length)}});await progressDone;setSaveStatus('saving',`${Math.min(ids.length,offset+batch.length)}/${ids.length}`);await new Promise(resolve=>setTimeout(resolve,0));
|
|||
|
|
}
|
|||
|
|
const imported=importedGlobalFromPortable(portableGlobal,epoch,{nextId:maxNumber+1,solved,boardScore}),provisional={epoch,status:'staging',schema:SAVE_SCHEMA,worldGeneration:WORLD_GENERATION,global:globalForStorage(imported),boardCount:ids.length,solvedCount:solved,score:imported.score,bounds:fieldBoundsFromMetas(snapshot.metas),approximateBytes,createdAt:trustedNow(),source:{kind:'import'},progress:{phase:'verify',rows:ids.length}},
|
|||
|
|
tx=db.transaction('worlds','readwrite'),done=transactionDone(tx);tx.objectStore('worlds').put(provisional);await done;
|
|||
|
|
const verified=await validateStagedWorldV2(epoch,{expectedBoardCount:ids.length,strict:false,allowStaging:true});await verifyStagedStorageEstimate();const world={...provisional,status:'ready',boardCount:verified.boardCount,solvedCount:verified.solvedCount,score:verified.score,bounds:verified.bounds,approximateBytes:verified.approximateBytes,progress:null};
|
|||
|
|
const readyTx=db.transaction('worlds','readwrite'),readyDone=transactionDone(readyTx);readyTx.objectStore('worlds').put(world);await readyDone;return{epoch,expected,world,recovery:true};
|
|||
|
|
}catch(error){await deleteV2Epoch(epoch).catch(()=>{});throw error}
|
|||
|
|
}
|
|||
|
|
async function activateReadyWorldV2(staged,{kind='import'}={}){
|
|||
|
|
return withWorldMutationLock(async()=>{
|
|||
|
|
if(hasPendingPersistence())await persistDirtyToDb({skipCloud:true});
|
|||
|
|
const db=await openWorldDb(),tx=db.transaction(['control','worlds'],'readwrite'),done=transactionDone(tx),controlStore=tx.objectStore('control'),worldsStore=tx.objectStore('worlds'),
|
|||
|
|
[control,currentWorld,readyWorld]=await Promise.all([requestValue(controlStore.get('active')),requestValue(worldsStore.get(staged.expected.epoch)),requestValue(worldsStore.get(staged.epoch))]);
|
|||
|
|
const olderPrevious=control?.previousEpoch&&control.previousEpoch!==staged.expected.epoch?await requestValue(worldsStore.get(control.previousEpoch)):null;
|
|||
|
|
if(!control||control.activeEpoch!==staged.expected.epoch||control.activeFormat!==FIELD_STORAGE_FORMAT||(currentWorld?.global?.globalRev||0)!==staged.expected.globalRev){try{tx.abort()}catch(_){}await done.catch(()=>{});throw new Error('The active field changed while the replacement was being validated. Please try again.')}
|
|||
|
|
if(!readyWorld||readyWorld.status!=='ready'){try{tx.abort()}catch(_){}await done.catch(()=>{});throw new Error('The staged field is not ready to activate.')}
|
|||
|
|
if(olderPrevious){olderPrevious.status='garbage';olderPrevious.progress={phase:'cleanup',store:'boardIndex',rows:0};worldsStore.put(olderPrevious)}if(currentWorld){currentWorld.status='rollback';currentWorld.progress=null;worldsStore.put(currentWorld)}readyWorld.status='active';readyWorld.activatedAt=trustedNow();
|
|||
|
|
const nextControl={key:'active',activeFormat:FIELD_STORAGE_FORMAT,activeEpoch:staged.epoch,previousEpoch:staged.expected.epoch,activationId:`${kind}:${sessionId}:${Date.now()}`,activationVerified:false,switchedAt:trustedNow()};
|
|||
|
|
worldsStore.put(readyWorld);controlStore.put(nextControl);
|
|||
|
|
await done;return nextControl;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
async function activateStagedWorldV2(staged,{kind='import'}={}){
|
|||
|
|
let control;try{control=await activateReadyWorldV2(staged,{kind})}catch(error){await deleteV2Epoch(staged?.epoch).catch(()=>{});throw error}lifecyclePersistenceSuppressed=true;clearTimeout(saveTimer);saveTimer=null;cancelMirrorCheckpoint(true);clearCompactMirror();clearRecoveryJournalKeys();safeLocalRemove(recoveryStorageKey);try{sessionStorage.removeItem(recoveryStorageKey)}catch(_){}volatileRecovery=null;safeLocalRemove(storageRevisionKey);rememberWorldEpoch(control.activeEpoch);announceWorldReplacement(control.activeEpoch);location.reload();return true;
|
|||
|
|
}
|
|||
|
|
async function importSaveFile(file){
|
|||
|
|
if(!file)return false;
|
|||
|
|
if(!idbAvailable)throw new Error('IndexedDB is required to restore a field.');
|
|||
|
|
const controller=new AbortController();activeArchiveController=controller;setFieldArchiveBusy(true,'\u30d5\u30a3\u30fc\u30eb\u30c9\u3092\u8aad\u307f\u8fbc\u3093\u3067\u3044\u307e\u3059\u3002');
|
|||
|
|
try{
|
|||
|
|
if(!await flushSave())throw new Error('The current field could not be checkpointed before import.');
|
|||
|
|
if(activeStorageFormat!==FIELD_STORAGE_FORMAT)throw new Error('現行の保存形式ではありません。');
|
|||
|
|
const expected=await activeWorldExpectation(),summary=assertCurrentArchive(await FieldPersistence.inspectArchive(file,{signal:controller.signal}));
|
|||
|
|
if(!Number.isSafeInteger(summary.boardCount)||summary.boardCount<1||summary.boardCount>MAX_BOARDS)throw new Error('The archive board count is outside the supported range.');
|
|||
|
|
if(!confirm(`\u73fe\u5728\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u3092${summary.boardCount}\u76e4\u9762\u306e\u30d0\u30c3\u30af\u30a2\u30c3\u30d7\u3067\u7f6e\u304d\u63db\u3048\u307e\u3059\u304b\uff1f`))return false;
|
|||
|
|
const staged=await stageArchiveV2(file,controller,expected);
|
|||
|
|
return await activateStagedWorldV2(staged);
|
|||
|
|
}finally{if(!lifecyclePersistenceSuppressed){activeArchiveController=null;setFieldArchiveBusy(false)}}
|
|||
|
|
}
|
|||
|
|
const importFileEl=document.querySelector('#importFile'),importBtn=document.querySelector('#importBtn');
|
|||
|
|
if(importBtn&&importFileEl){
|
|||
|
|
importBtn.onclick=()=>{importFileEl.value='';importFileEl.click()};
|
|||
|
|
importFileEl.onchange=async()=>{try{await importSaveFile(importFileEl.files?.[0])}catch(error){showStatus(`\u4fdd\u5b58\u30c7\u30fc\u30bf\u3092\u8aad\u307f\u8fbc\u3081\u307e\u305b\u3093\u3067\u3057\u305f\u3002 ${error?.message||error}`,{retry:false,fresh:false});toast('\u4fdd\u5b58\u30c7\u30fc\u30bf\u3092\u8aad\u307f\u8fbc\u3081\u307e\u305b\u3093\u3067\u3057\u305f\u3002')}};
|
|||
|
|
}
|
|||
|
|
async function retryRecovery(){
|
|||
|
|
hideStatus();if(!puzzleWorker)createPuzzleWorker();
|
|||
|
|
try{
|
|||
|
|
const envelope=await readRecoveryEnvelope();if(!envelope?.raw)throw new Error('復元用バックアップがありません。');
|
|||
|
|
const parsed=JSON.parse(envelope.raw),candidate=parsed?.current??parsed,snapshot=normalizeSnapshot(candidate,{quiet:true});
|
|||
|
|
if(!Object.keys(snapshot.metas).length)throw new Error('復元用バックアップに有効な盤面がありません。');
|
|||
|
|
if(activeStorageFormat!==FIELD_STORAGE_FORMAT)throw new Error('現行の保存形式ではありません。');
|
|||
|
|
const expected=await activeWorldExpectation(),controller=new AbortController(),staged=await stageRecoverySnapshotV2(snapshot,expected,controller);
|
|||
|
|
await activateStagedWorldV2(staged,{kind:'recovery'});
|
|||
|
|
}
|
|||
|
|
catch(error){console.warn('BEND FIELD: recovery retry failed',error);showStatus(`保存データを復元できませんでした。 ${error?.message||error}`,{retry:true,fresh:false})}
|
|||
|
|
}
|
|||
|
|
document.querySelector('#retryBtn').onclick=retryRecovery;
|
|||
|
|
const worldChannel=typeof BroadcastChannel!=='undefined'?new BroadcastChannel(`bend-field-v${SAVE_SCHEMA}-${WORLD_GENERATION}`):null;
|
|||
|
|
const seenWorldCommitIds=new Set(),seenWorldCommitOrder=[],pendingWorldCommitIds=new Set();
|
|||
|
|
let worldInitReady=false,deferredWorldSignals=[];
|
|||
|
|
function worldCommitId(signal){return typeof signal?.commitId==='string'?signal.commitId:signal?.sessionId&&signal?.updatedAt?`${signal.sessionId}:${signal.updatedAt}`:null}
|
|||
|
|
function rememberWorldCommit(signal){
|
|||
|
|
const id=worldCommitId(signal);if(!id)return true;if(seenWorldCommitIds.has(id))return false;
|
|||
|
|
seenWorldCommitIds.add(id);seenWorldCommitOrder.push(id);if(seenWorldCommitOrder.length>512)seenWorldCommitIds.delete(seenWorldCommitOrder.shift());return true;
|
|||
|
|
}
|
|||
|
|
function broadcastWorldSignal(signal){rememberWorldCommit(signal);try{worldChannel?.postMessage(signal)}catch(_){}safeLocalSet(syncStorageKey,JSON.stringify(signal))}
|
|||
|
|
function announceWorldReplacement(worldEpoch){const signal={type:'world-replaced',commitId:`${sessionId}:replacement:${++worldSignalSeq}`,updatedAt:trustedNow(),sessionId,worldEpoch,metaIds:[],stateIds:[],deleted:[],globalChanged:true};broadcastWorldSignal(signal)}
|
|||
|
|
let syncQueue=Promise.resolve();
|
|||
|
|
async function loadDbChanges(signal){
|
|||
|
|
if(!idbAvailable||!validWorldEpoch(data?.worldEpoch))return null;
|
|||
|
|
const ids=[...new Set([...(signal.metaIds||[]),...(signal.stateIds||[]),...(signal.deleted||[])])],db=await openWorldDb(),tx=db.transaction(['control','worlds','boardIndex','boardPuzzles','boardStates','tombstonesV2'],'readonly'),done=transactionDone(tx),
|
|||
|
|
controlStore=tx.objectStore('control'),worldsStore=tx.objectStore('worlds'),indexStore=tx.objectStore('boardIndex'),puzzleStore=tx.objectStore('boardPuzzles'),stateStore=tx.objectStore('boardStates'),tombstoneStore=tx.objectStore('tombstonesV2'),
|
|||
|
|
keys=ids.map(id=>[data.worldEpoch,id]);
|
|||
|
|
const [control,world,indexRows,puzzleRows,stateRecords,tombstoneRows]=await Promise.all([
|
|||
|
|
requestValue(controlStore.get('active')),requestValue(worldsStore.get(data.worldEpoch)),
|
|||
|
|
Promise.all(keys.map(key=>requestValue(indexStore.get(key)))),Promise.all(keys.map(key=>requestValue(puzzleStore.get(key)))),
|
|||
|
|
Promise.all(keys.map(key=>requestValue(stateStore.get(key)))),Promise.all(keys.map(key=>requestValue(tombstoneStore.get(key))))
|
|||
|
|
]);await done;
|
|||
|
|
const metaRows=ids.map((id,index)=>metaFromV2Records(indexRows[index],puzzleRows[index])),
|
|||
|
|
stateRows=ids.map((id,index)=>{const value=stateFromV2Record(indexRows[index],stateRecords[index]);return value?{id,value}:null});
|
|||
|
|
return{ids,metaRows,stateRows,tombstoneRows,metaById:new Map(ids.map((id,index)=>[id,metaRows[index]||null])),tombstoneById:new Map(ids.map((id,index)=>[id,tombstoneRows[index]||null])),global:world?.global,worldEpoch:control?.activeEpoch||world?.epoch||null};
|
|||
|
|
}
|
|||
|
|
function mergeGlobalFields(external){
|
|||
|
|
if(!external)return;const current=globalForStorage(data,data.updatedAt||data.clockFloor||0),merged=mergeGlobalRecords(current,external);applyGlobalRecordToData(merged);
|
|||
|
|
}
|
|||
|
|
function preserveBoardEditingBeforeSync(id,{meta=false}={}){
|
|||
|
|
const board=rendered.get(id);if(!board?.drawing)return false;
|
|||
|
|
const pointerId=board.drawing.pointerId;board.drawing=null;board.armedGate=null;cancelBoardDragFrame(board);clearDragRender(board);
|
|||
|
|
if(pointerId!=null)safeRelease(board.svg,pointerId);else queueMicrotask(reconcileDrawingPresentation);
|
|||
|
|
const state=data.states[id];if(state){state.rev=nextRevision();markStateDirty(id)}
|
|||
|
|
if(meta&&data.metas[id]){data.metas[id].rev=nextRevision();markMetaDirty(id)}
|
|||
|
|
renderBoard(board);return true;
|
|||
|
|
}
|
|||
|
|
async function applyWorldSignal(signal){
|
|||
|
|
if(!signal||signal.sessionId===sessionId)return;
|
|||
|
|
if(signal.type==='world-replaced'||validWorldEpoch(signal.worldEpoch)&&validWorldEpoch(data.worldEpoch)&&signal.worldEpoch!==data.worldEpoch){lifecyclePersistenceSuppressed=true;rememberWorldEpoch(signal.worldEpoch);setTimeout(()=>location.reload(),50);return}
|
|||
|
|
await waitForInteractionSettle();
|
|||
|
|
const metaOrDelete=new Set([...(signal.metaIds||[]),...(signal.deleted||[])]),affected=new Set([...metaOrDelete,...(signal.stateIds||[])]);
|
|||
|
|
for(const id of affected)preserveBoardEditingBeforeSync(id,{meta:metaOrDelete.has(id)});
|
|||
|
|
const delta=await loadDbChanges(signal);if(!delta)return;if(validWorldEpoch(delta.worldEpoch)&&delta.worldEpoch!==data.worldEpoch){lifecyclePersistenceSuppressed=true;rememberWorldEpoch(delta.worldEpoch);setTimeout(()=>location.reload(),50);return}
|
|||
|
|
for(const id of affected)preserveBoardEditingBeforeSync(id,{meta:metaOrDelete.has(id)});mergeGlobalFields(delta.global);
|
|||
|
|
const added=[];for(const row of delta.metaRows.filter(Boolean)){const incoming=normalizeMeta(row.id,row);if(!incoming)continue;const current=data.metas[row.id];if(!current||compareRevisionVersions(incoming,current)>0){destroyBoard(rendered.get(row.id));destroyStaticBoard(staticRendered.get(row.id));data.metas[row.id]=incoming;added.push(row.id)}}
|
|||
|
|
for(const row of delta.stateRows.filter(Boolean)){if(!data.metas[row.id])continue;const incoming=normalizeState(row.value),current=data.states[row.id];if(!current||compareRevisionVersions(incoming,current)>0){data.states[row.id]=incoming;normalizedStateObjects.add(incoming);const board=rendered.get(row.id);if(board)board.solvedPathsRendered=false;rememberStateSignatures(row.id,incoming)}}
|
|||
|
|
for(const id of signal.deleted||[]){
|
|||
|
|
const tombstone=delta.tombstoneById.get(id);if(delta.metaById.get(id)||!tombstone||compareRevisionVersions(tombstone,data.metas[id])<0||compareRevisionVersions(tombstone,data.states[id])<0||dirtyMetaIds.has(id)||dirtyStateIds.has(id))continue;
|
|||
|
|
delete data.metas[id];delete data.states[id];stateStatSignatures.delete(id);stateEconomySignatures.delete(id);destroyBoard(rendered.get(id));destroyStaticBoard(staticRendered.get(id));if(activeBoard===id)activeBoard=null;if(hudBoardId===id)hudBoardId=null;
|
|||
|
|
}
|
|||
|
|
refreshWorldView({rebuild:true,syncConnections:true,markStats:true,resumeTimer:true,persist:true});
|
|||
|
|
for(const id of added)if(visibleMetaIds().has(id))try{await hydrateMeta(data.metas[id])}catch(error){data.quarantine[id]={failedAt:trustedNow(),message:String(error?.message||error),retries:(data.quarantine[id]?.retries||0)+1}}
|
|||
|
|
}
|
|||
|
|
function queueWorldSignal(signal){
|
|||
|
|
if(!signal||signal.sessionId===sessionId)return;
|
|||
|
|
if(!worldInitReady){deferredWorldSignals.push(signal);if(deferredWorldSignals.length>256)deferredWorldSignals.shift();return}
|
|||
|
|
const id=worldCommitId(signal);if(id&&(seenWorldCommitIds.has(id)||pendingWorldCommitIds.has(id)))return;if(id)pendingWorldCommitIds.add(id);
|
|||
|
|
const run=async()=>{try{await applyWorldSignal(signal);rememberWorldCommit(signal)}catch(error){if((signal._retryCount||0)<3)setTimeout(()=>queueWorldSignal({...signal,_retryCount:(signal._retryCount||0)+1}),250*2**(signal._retryCount||0));else console.warn('BEND FIELD: cross-tab sync failed',error)}finally{if(id)pendingWorldCommitIds.delete(id)}};
|
|||
|
|
syncQueue=syncQueue.then(run,run);
|
|||
|
|
}
|
|||
|
|
function drainWorldSignals(){worldInitReady=true;const pending=deferredWorldSignals;deferredWorldSignals=[];for(const signal of pending)queueWorldSignal(signal)}
|
|||
|
|
worldChannel?.addEventListener('message',event=>queueWorldSignal(event.data));
|
|||
|
|
window.addEventListener('storage',event=>{
|
|||
|
|
if(event.key!==syncStorageKey||!event.newValue)return;try{queueWorldSignal(JSON.parse(event.newValue))}catch(error){console.warn('BEND FIELD: ignored malformed sync signal',error)}
|
|||
|
|
});
|
|||
|
|
function checkpointForLifecycle(){if(lifecyclePersistenceSuppressed)return;writeDirtyRecoveryJournal();void flushSave({lifecycle:true})}
|
|||
|
|
window.addEventListener('pagehide',checkpointForLifecycle);
|
|||
|
|
document.addEventListener('visibilitychange',()=>{const hidden=document.visibilityState==='hidden';document.body.classList.toggle('effects-paused',hidden);if(hidden){cancelReactionGesture();hideRealtimeCursor();clearTimeout(realtimeHeartbeatTimer);realtimeHeartbeatTimer=0;pauseNoiseBackground();checkpointForLifecycle();void pushCloudPending()}else{scheduleNoiseBackground(true);resumeTimeAttackTimer();connectRealtime();scheduleRealtimeViewport(true);scheduleRealtimeHeartbeat();schedulePresenceRender(true);scheduleReactionRender();void pullCloudWorld();scheduleMirrorCheckpoint()}});
|
|||
|
|
const cloudBtn=document.querySelector('#cloudBtn');
|
|||
|
|
function emptyCloudPending(){return{metaIds:new Set(),stateIds:new Set(),deleted:new Set(),globalChanged:false}}
|
|||
|
|
let cloudAvailable=false,cloudPushTimer=null,cloudPushPending=emptyCloudPending(),cloudSyncing=false,cloudCheckpointRetryTimer=0,worldPollTimer=0,lastCloudWorldGlobalSignature='';
|
|||
|
|
const remotePlayers=new Map(),boardClaims=new Map(),realtimeClaimRequests=new Map(),remoteCursorImageCache=new Map(),realtimeHeldPointers=new Set(),realtimeReactions=new Map();
|
|||
|
|
let realtimeSocket=null,realtimeReady=false,realtimePresenceId=null,realtimeClaimTtlMs=5*60*1000,realtimeReconnectTimer=0,realtimeViewportTimer=0,realtimeCursorTimer=0,realtimeHeartbeatTimer=0,realtimeLastCursorSentAt=0,realtimeLastCursorSampleAt=0,realtimePendingCursor=null,realtimePendingCursorClient=null,realtimeRequestSequence=0,realtimeOwnClaimBoardId=null,realtimeLastClaimTouchAt=0,presenceFrame=0,presenceDelayTimer=0,presenceLastDraw=0,presenceDirty=true,presenceLastTimestamp=0,presenceCameraSnapshot=null,reactionFrame=0,reactionDelayTimer=0,reactionLastDraw=0,reactionDirty=true,reactionCameraSnapshot=null,reactionGesture=null,reactionSequence=0;
|
|||
|
|
function realtimeWebSocketUrl(){const protocol=location.protocol==='https:'?'wss:':'ws:';return`${protocol}//${location.host}/api/realtime`}
|
|||
|
|
function realtimeSend(message){if(!realtimeReady||realtimeSocket?.readyState!==WebSocket.OPEN)return false;try{realtimeSocket.send(JSON.stringify(message));return true}catch(_){return false}}
|
|||
|
|
function realtimeViewportBounds(){const rect=getViewportRect(),topLeft=worldUnitAtClient(rect.left,rect.top),bottomRight=worldUnitAtClient(rect.right,rect.bottom);return{minX:Math.min(topLeft[0],bottomRight[0]),minY:Math.min(topLeft[1],bottomRight[1]),maxX:Math.max(topLeft[0],bottomRight[0]),maxY:Math.max(topLeft[1],bottomRight[1])}}
|
|||
|
|
function sendRealtimeViewport(){realtimeViewportTimer=0;if(!realtimeReady)return false;const bounds=realtimeViewportBounds();return realtimeSend({type:'viewport',...bounds})}
|
|||
|
|
function scheduleRealtimeViewport(immediate=false){if(!realtimeReady)return false;if(immediate){clearTimeout(realtimeViewportTimer);realtimeViewportTimer=0;return sendRealtimeViewport()}if(realtimeViewportTimer)return true;realtimeViewportTimer=setTimeout(sendRealtimeViewport,REALTIME_VIEWPORT_INTERVAL);return true}
|
|||
|
|
function scheduleRealtimeHeartbeat(){clearTimeout(realtimeHeartbeatTimer);realtimeHeartbeatTimer=0;if(!realtimeReady||document.visibilityState==='hidden')return false;realtimeHeartbeatTimer=setTimeout(()=>{realtimeHeartbeatTimer=0;if(realtimePendingCursor)realtimeSend({type:'cursor',...realtimePendingCursor,vx:0,vy:0,sentAt:Date.now()});scheduleRealtimeHeartbeat()},REALTIME_CURSOR_HEARTBEAT_INTERVAL);return true}
|
|||
|
|
function queueRealtimeCursor(event){
|
|||
|
|
if(!realtimeReady||event.pointerType==='touch'||document.body.classList.contains('is-drawing')||!viewport.contains(event.target))return;
|
|||
|
|
realtimePendingCursorClient={clientX:event.clientX,clientY:event.clientY,inputAt:Number(event.timeStamp)||perfNow()};
|
|||
|
|
const wait=REALTIME_CURSOR_INTERVAL-(perfNow()-realtimeLastCursorSentAt);if(realtimeCursorTimer)return;
|
|||
|
|
realtimeCursorTimer=setTimeout(()=>{
|
|||
|
|
realtimeCursorTimer=0;const client=realtimePendingCursorClient;if(!client||!realtimeReady)return;
|
|||
|
|
const point=worldUnitAtClient(client.clientX,client.clientY),previous=realtimePendingCursor,now=perfNow(),elapsed=Math.max(1,now-(realtimeLastCursorSampleAt||now)),
|
|||
|
|
next={x:point[0],y:point[1],vx:previous?(point[0]-previous.x)*1000/elapsed:0,vy:previous?(point[1]-previous.y)*1000/elapsed:0,sentAt:Date.now(),cursorStyle:data.cursorStyle||'default'};
|
|||
|
|
realtimeLastCursorSampleAt=now;
|
|||
|
|
realtimePendingCursor=next;realtimeLastCursorSentAt=perfNow();
|
|||
|
|
if(previous&&previous.cursorStyle===next.cursorStyle&&Math.abs(previous.x-next.x)<.002&&Math.abs(previous.y-next.y)<.002)return;
|
|||
|
|
realtimeSend({type:'cursor',...next});
|
|||
|
|
},Math.max(0,wait));
|
|||
|
|
}
|
|||
|
|
function hideRealtimeCursor(){clearTimeout(realtimeCursorTimer);realtimeCursorTimer=0;realtimeLastCursorSampleAt=0;realtimePendingCursorClient=null;realtimePendingCursor=null;if(realtimeReady)realtimeSend({type:'cursor-hide'})}
|
|||
|
|
function remoteCursorImage(item){
|
|||
|
|
if(!item?.flagAsset)return null;let entry=remoteCursorImageCache.get(item.flagAsset);if(entry){remoteCursorImageCache.delete(item.flagAsset);remoteCursorImageCache.set(item.flagAsset,entry);return entry.image}
|
|||
|
|
const image=new Image();entry={image,ready:false};remoteCursorImageCache.set(item.flagAsset,entry);while(remoteCursorImageCache.size>48)remoteCursorImageCache.delete(remoteCursorImageCache.keys().next().value);image.onload=()=>{entry.ready=true;schedulePresenceRender(true)};image.onerror=()=>{entry.failed=true};image.src=item.flagAsset;return image;
|
|||
|
|
}
|
|||
|
|
function roundedCanvasRect(context,x,y,width,height,radius){const r=Math.max(0,Math.min(radius,width/2,height/2));context.beginPath();context.moveTo(x+r,y);context.arcTo(x+width,y,x+width,y+height,r);context.arcTo(x+width,y+height,x,y+height,r);context.arcTo(x,y+height,x,y,r);context.arcTo(x,y,x+width,y,r);context.closePath()}
|
|||
|
|
function drawDefaultRemoteCursor(context,x,y){context.save();context.translate(x,y);context.beginPath();context.moveTo(-6,-10);context.lineTo(9,1);context.lineTo(2,3);context.lineTo(6,11);context.lineTo(1,13);context.lineTo(-3,5);context.lineTo(-8,10);context.closePath();context.lineJoin='round';context.lineWidth=4;context.strokeStyle='rgba(8,12,15,.95)';context.stroke();context.lineWidth=2;context.strokeStyle='#fff';context.stroke();context.fillStyle='#d9f06f';context.fill();context.restore()}
|
|||
|
|
function drawRemoteCursorGlyph(context,player,x,y){
|
|||
|
|
const item=CURSOR_ITEM_BY_STYLE.get(player.cursorStyle);
|
|||
|
|
if(!item){drawDefaultRemoteCursor(context,x,y);return}
|
|||
|
|
if(item.flagAsset){const entry=remoteCursorImageCache.get(item.flagAsset),image=remoteCursorImage(item);if(entry?.ready){context.save();context.translate(x,y);roundedCanvasRect(context,-15,-11,30,22,4);context.clip();context.drawImage(image,-17,-13,34,26);context.restore();context.strokeStyle='rgba(255,255,255,.9)';context.lineWidth=1.5;roundedCanvasRect(context,x-15,y-11,30,22,4);context.stroke()}else drawDefaultRemoteCursor(context,x,y);return}
|
|||
|
|
context.save();context.font='25px "Segoe UI Emoji","Apple Color Emoji","Noto Color Emoji",sans-serif';context.textAlign='center';context.textBaseline='middle';context.shadowColor='rgba(0,0,0,.8)';context.shadowBlur=4;context.fillText(item.cursorEmoji||'•',x,y);context.restore();
|
|||
|
|
}
|
|||
|
|
function drawRemotePlayerName(context,player,x,y){
|
|||
|
|
const name=String(player.name||'旅人').slice(0,24);context.save();context.font='700 10px DotGothic16Local,monospace';context.textBaseline='middle';const width=Math.ceil(context.measureText(name).width)+10,left=x+12,top=y+11;roundedCanvasRect(context,left,top,width,18,3);context.fillStyle='rgba(8,12,15,.88)';context.fill();context.strokeStyle='rgba(255,255,255,.3)';context.lineWidth=1;context.stroke();context.fillStyle='#fff';context.fillText(name,left+5,top+9);context.restore();
|
|||
|
|
}
|
|||
|
|
function onlineLayerCameraSnapshot(){const scale=Math.max(MIN_CAMERA_SCALE,cam.scale);return{scale,offsetX:cam.x-renderOriginX*UNIT*scale,offsetY:cam.y-renderOriginY*UNIT*scale}}
|
|||
|
|
function shiftCameraBoundCanvas(canvas,snapshot){
|
|||
|
|
if(!canvas||!snapshot)return false;const current=onlineLayerCameraSnapshot(),ratio=current.scale/snapshot.scale,
|
|||
|
|
tx=current.offsetX-ratio*snapshot.offsetX,ty=current.offsetY-ratio*snapshot.offsetY;
|
|||
|
|
canvas.style.transform=`matrix(${ratio.toFixed(6)},0,0,${ratio.toFixed(6)},${tx.toFixed(2)},${ty.toFixed(2)})`;return true;
|
|||
|
|
}
|
|||
|
|
function shiftOnlineLayersForCamera(){
|
|||
|
|
if(remotePlayers.size)shiftCameraBoundCanvas(presenceCanvas,presenceCameraSnapshot);
|
|||
|
|
if(realtimeReactions.size)shiftCameraBoundCanvas(reactionCanvas,reactionCameraSnapshot);
|
|||
|
|
}
|
|||
|
|
function redrawOnlineLayersAfterCamera(){
|
|||
|
|
// Keep the camera compensation transform until the fresh frame is painted.
|
|||
|
|
// Clearing it here exposes the stale bitmap for one frame and causes a jump.
|
|||
|
|
if(remotePlayers.size||presenceDirty)schedulePresenceRender(true);if(realtimeReactions.size||reactionDirty)scheduleReactionRender(true);
|
|||
|
|
}
|
|||
|
|
function schedulePresenceRender(markDirty=false){
|
|||
|
|
if(markDirty)presenceDirty=true;if(!presenceCanvas||presenceFrame||(!presenceDirty&&!remotePlayers.size))return;
|
|||
|
|
const step=timestamp=>{if(!presenceFrame)return;if(timestamp-presenceLastDraw<AUXILIARY_FRAME_INTERVAL-1){presenceFrame=requestAnimationFrame(step);return}presenceFrame=0;presenceLastDraw=timestamp;markVisualFrame(timestamp);drawPresenceLayer(timestamp)};
|
|||
|
|
presenceFrame=requestAnimationFrame(step);
|
|||
|
|
}
|
|||
|
|
function drawPresenceLayer(timestamp=perfNow()){
|
|||
|
|
if(!presenceCanvas)return;const rect=getViewportRect(),width=Math.max(1,Math.round(rect.width)),height=Math.max(1,Math.round(rect.height)),dpr=1,pixelWidth=Math.round(width*dpr),pixelHeight=Math.round(height*dpr);
|
|||
|
|
if(presenceCanvas.width!==pixelWidth||presenceCanvas.height!==pixelHeight){presenceCanvas.width=pixelWidth;presenceCanvas.height=pixelHeight;presenceDirty=true}
|
|||
|
|
presenceCanvas.style.transform='none';presenceCameraSnapshot=onlineLayerCameraSnapshot();
|
|||
|
|
const context=presenceCanvas.getContext('2d');context.setTransform(dpr,0,0,dpr,0,0);context.clearRect(0,0,width,height);const now=Date.now(),dt=Math.min(80,Math.max(1,timestamp-(presenceLastTimestamp||timestamp-33)));presenceLastTimestamp=timestamp;let animate=false,playersRemoved=false;
|
|||
|
|
for(const[presenceId,player]of remotePlayers){if(now-(player.lastSeen||0)>REALTIME_PLAYER_STALE_MS){remotePlayers.delete(presenceId);playersRemoved=true;continue}if(!Number.isFinite(player.currentX)){player.currentX=player.targetX;player.currentY=player.targetY}const alpha=1-Math.exp(-dt/45);player.currentX+=(player.targetX-player.currentX)*alpha;player.currentY+=(player.targetY-player.currentY)*alpha;if(Math.abs(player.targetX-player.currentX)>.001||Math.abs(player.targetY-player.currentY)>.001)animate=true;const x=(player.currentX-renderOriginX)*UNIT*cam.scale+cam.x,y=(player.currentY-renderOriginY)*UNIT*cam.scale+cam.y;if(x<-80||y<-80||x>width+120||y>height+80)continue;drawRemoteCursorGlyph(context,player,x,y);drawRemotePlayerName(context,player,x,y)}
|
|||
|
|
presenceDirty=false;if(animate)schedulePresenceRender();else if(remotePlayers.size&&!presenceDelayTimer)presenceDelayTimer=setTimeout(()=>{presenceDelayTimer=0;schedulePresenceRender(true)},1000);if(playersRemoved)scheduleMinimap(true);
|
|||
|
|
}
|
|||
|
|
function applyRemotePlayer(raw){
|
|||
|
|
if(!raw||raw.presenceId===realtimePresenceId||!Number.isFinite(raw.x)||!Number.isFinite(raw.y))return false;
|
|||
|
|
const vx=Number.isFinite(raw.vx)?raw.vx:0,vy=Number.isFinite(raw.vy)?raw.vy:0,lead=.035,previous=remotePlayers.get(raw.presenceId)||{currentX:raw.x,currentY:raw.y};
|
|||
|
|
previous.presenceId=raw.presenceId;previous.playerId=raw.playerId;previous.name=String(raw.name||'旅人').slice(0,24);previous.cursorStyle=String(raw.cursorStyle||'default');previous.targetX=raw.x+vx*lead;previous.targetY=raw.y+vy*lead;previous.vx=vx;previous.vy=vy;previous.sentAt=Number(raw.sentAt)||0;previous.lastSeen=Date.now();remotePlayers.set(raw.presenceId,previous);schedulePresenceRender(true);scheduleMinimap(true);return true;
|
|||
|
|
}
|
|||
|
|
function normalizeRealtimeReaction(raw){const emoji=String(raw?.emoji||''),x=Number(raw?.x),y=Number(raw?.y),createdAt=Number(raw?.createdAt),expiresAt=Number(raw?.expiresAt),id=typeof raw?.id==='string'?raw.id.slice(0,80):'';if(!id||!REACTION_EMOJIS.includes(emoji)||!Number.isFinite(x)||!Number.isFinite(y)||!Number.isFinite(createdAt)||!Number.isFinite(expiresAt)||expiresAt<=trustedNow())return null;return{id,emoji,x,y,createdAt,expiresAt,playerId:raw.playerId||null,playerName:String(raw.playerName||'').slice(0,24)}}
|
|||
|
|
function applyRealtimeReaction(raw){const reaction=normalizeRealtimeReaction(raw);if(!reaction)return false;realtimeReactions.set(reaction.id,reaction);reactionDirty=true;scheduleReactionRender();return true}
|
|||
|
|
function scheduleReactionRender(markDirty=false){if(markDirty)reactionDirty=true;if(!reactionCanvas||reactionFrame||(!reactionDirty&&!realtimeReactions.size))return;const step=timestamp=>{if(!reactionFrame)return;if(timestamp-reactionLastDraw<AUXILIARY_FRAME_INTERVAL-1){reactionFrame=requestAnimationFrame(step);return}reactionFrame=0;reactionLastDraw=timestamp;markVisualFrame(timestamp);drawReactionLayer(timestamp)};reactionFrame=requestAnimationFrame(step)}
|
|||
|
|
function drawReactionLayer(){if(!reactionCanvas)return;const rect=getViewportRect(),width=Math.max(1,Math.round(rect.width)),height=Math.max(1,Math.round(rect.height)),dpr=1,pixelWidth=Math.round(width*dpr),pixelHeight=Math.round(height*dpr);if(reactionCanvas.width!==pixelWidth||reactionCanvas.height!==pixelHeight){reactionCanvas.width=pixelWidth;reactionCanvas.height=pixelHeight}reactionCanvas.style.transform='none';reactionCameraSnapshot=onlineLayerCameraSnapshot();const context=reactionCanvas.getContext('2d',{alpha:true});context.setTransform(dpr,0,0,dpr,0,0);context.clearRect(0,0,width,height);reactionDirty=false;const timestamp=trustedNow();let active=false;for(const[id,reaction]of realtimeReactions){if(reaction.expiresAt<=timestamp){realtimeReactions.delete(id);continue}active=true;const life=Math.max(0,Math.min(1,(timestamp-reaction.createdAt)/(reaction.expiresAt-reaction.createdAt))),appear=Math.min(1,life/.035),fade=life>.84?(1-life)/.16:1,scale=.45+.55*(1-Math.pow(1-appear,3)),x=(reaction.x-renderOriginX)*UNIT*cam.scale+cam.x,y=(reaction.y-renderOriginY)*UNIT*cam.scale+cam.y-18*life;if(x<-60||y<-80||x>width+60||y>height+60)continue;context.save();context.globalAlpha=Math.max(0,fade);context.translate(x,y);context.scale(scale,scale);context.font='34px "Segoe UI Emoji","Apple Color Emoji","Noto Color Emoji",sans-serif';context.textAlign='center';context.textBaseline='middle';context.shadowColor='rgba(0,0,0,.58)';context.shadowBlur=5;context.fillText(reaction.emoji,0,0);context.restore()}if(active)scheduleReactionRender()}
|
|||
|
|
function publishReactionAt(emoji,x,y){if(!REACTION_EMOJIS.includes(emoji)||!Number.isFinite(x)||!Number.isFinite(y))return false;data.lastReaction=emoji;markGlobalDirty(false);const timestamp=Date.now(),reaction={id:`local-${sessionId}-${++reactionSequence}`,emoji,x,y,createdAt:timestamp,expiresAt:timestamp+REALTIME_REACTION_DURATION,playerId:currentPlayerId(),playerName:currentPlayerName()};applyRealtimeReaction(reaction);if(realtimeReady)realtimeSend({type:'reaction',id:reaction.id,emoji,x,y});return true}
|
|||
|
|
function reactionAllowedAt(clientX,clientY,target){if(target?.closest?.('button,a,input,select,textarea,[role="button"]'))return false;const[x,y]=worldUnitAtClient(clientX,clientY),boardId=occupancy.get(key2(Math.floor(x),Math.floor(y)));return!boardId||metaState(boardId).solved}
|
|||
|
|
function hideReactionRadial(){if(reactionRadial)reactionRadial.hidden=true;document.body.classList.remove('reaction-selecting')}
|
|||
|
|
function radialReactionIndex(clientX,clientY){if(!reactionGesture)return 0;const dx=clientX-reactionGesture.clientX,dy=clientY-reactionGesture.clientY,distance=Math.hypot(dx,dy);if(distance<18)return reactionGesture.selected??0;let best=0,bestDistance=Infinity;for(let index=0;index<REACTION_EMOJIS.length;index++){const angle=-Math.PI/2+index*Math.PI*2/REACTION_EMOJIS.length,cx=Math.cos(angle)*48,cy=Math.sin(angle)*48,delta=(dx-cx)**2+(dy-cy)**2;if(delta<bestDistance){best=index;bestDistance=delta}}return best}
|
|||
|
|
function updateReactionRadialSelection(clientX,clientY){if(!reactionGesture?.menuOpen)return;reactionGesture.selected=radialReactionIndex(clientX,clientY);for(const[ index,button]of[...reactionRadial.children].entries())button.classList.toggle('selected',index===reactionGesture.selected)}
|
|||
|
|
function openReactionRadial(){const gesture=reactionGesture;if(!gesture||gesture.moved)return;gesture.menuOpen=true;gesture.selected=REACTION_EMOJIS.indexOf(data.lastReaction);if(gesture.selected<0)gesture.selected=0;if(pan?.id===gesture.pointerId){pan=null;viewport.classList.remove('panning');refreshInteractionState()}reactionRadial.replaceChildren();REACTION_EMOJIS.forEach((emoji,index)=>{const button=document.createElement('button'),angle=-Math.PI/2+index*Math.PI*2/REACTION_EMOJIS.length;button.type='button';button.textContent=emoji;button.setAttribute('role','menuitem');button.style.setProperty('--rx',`${Math.cos(angle)*48}px`);button.style.setProperty('--ry',`${Math.sin(angle)*48}px`);button.classList.toggle('selected',index===gesture.selected);reactionRadial.append(button)});reactionRadial.style.left=`${gesture.clientX}px`;reactionRadial.style.top=`${gesture.clientY}px`;reactionRadial.hidden=false;document.body.classList.add('reaction-selecting')}
|
|||
|
|
function beginReactionGesture(event){if(event.button!==0||event.pointerType==='touch'&&!event.isPrimary||!reactionAllowedAt(event.clientX,event.clientY,event.target))return;const[x,y]=worldUnitAtClient(event.clientX,event.clientY);reactionGesture={pointerId:event.pointerId,clientX:event.clientX,clientY:event.clientY,x,y,moved:false,menuOpen:false,selected:REACTION_EMOJIS.indexOf(data.lastReaction),timer:setTimeout(openReactionRadial,REACTION_LONG_PRESS_MS)};try{viewport.setPointerCapture(event.pointerId)}catch(_){}}
|
|||
|
|
function moveReactionGesture(event){const gesture=reactionGesture;if(!gesture||gesture.pointerId!==event.pointerId)return;if(gesture.menuOpen){event.preventDefault();event.stopImmediatePropagation();updateReactionRadialSelection(event.clientX,event.clientY);return}if(Math.hypot(event.clientX-gesture.clientX,event.clientY-gesture.clientY)>REACTION_MOVE_CANCEL_PX){gesture.moved=true;clearTimeout(gesture.timer)}}
|
|||
|
|
function endReactionGesture(event){const gesture=reactionGesture;if(!gesture||gesture.pointerId!==event.pointerId)return;clearTimeout(gesture.timer);const index=gesture.menuOpen?radialReactionIndex(event.clientX,event.clientY):-1;reactionGesture=null;try{if(viewport.hasPointerCapture?.(event.pointerId))viewport.releasePointerCapture(event.pointerId)}catch(_){}if(gesture.menuOpen){event.preventDefault();event.stopImmediatePropagation();touchPoints.delete(event.pointerId);viewport.classList.remove('panning');refreshInteractionState();const emoji=REACTION_EMOJIS[index]||data.lastReaction;hideReactionRadial();publishReactionAt(emoji,gesture.x,gesture.y);return}hideReactionRadial();if(!gesture.moved)publishReactionAt(data.lastReaction||'👍',gesture.x,gesture.y)}
|
|||
|
|
function cancelReactionGesture(event=null){const pointerId=reactionGesture?.pointerId;if(reactionGesture?.timer)clearTimeout(reactionGesture.timer);reactionGesture=null;if(pointerId!=null)try{if(viewport.hasPointerCapture?.(pointerId))viewport.releasePointerCapture(pointerId)}catch(_){}hideReactionRadial()}
|
|||
|
|
function currentBoardClaim(boardId){const claim=boardClaims.get(boardId);if(claim&&claim.expiresAt<=trustedNow()){boardClaims.delete(boardId);refreshClaimPresentation(boardId);return null}return claim||null}
|
|||
|
|
function boardClaimOwnedByMe(boardId){const claim=currentBoardClaim(boardId);return Boolean(claim&&claim.playerId===currentPlayerId())}
|
|||
|
|
function boardClaimedByOther(boardId){const claim=currentBoardClaim(boardId);return Boolean(claim&&claim.playerId!==currentPlayerId())}
|
|||
|
|
function applyClaimPresentationToBoard(board){if(!board)return;const claim=currentBoardClaim(board.id),solved=metaState(board.id).solved,own=Boolean(claim&&claim.playerId===currentPlayerId()),hudVisible=boardPlayHudVisible(board,solved);board.card.classList.toggle('claimed-other',Boolean(claim&&!own&&!solved));board.card.classList.toggle('claimed-own',Boolean(claim&&own&&!solved));board.card.classList.toggle('hud-current',hudVisible);if(hudVisible)positionBoardLabel(board);if(board.claimBadge){board.claimBadge.hidden=!claim||solved;board.claimBadge.textContent=claim?own?'占有中':`${claim.playerName||'他のプレイヤー'}がプレイ中`:''}board.svg?.setAttribute?.('aria-disabled',String(Boolean(claim&&!own&&!solved)))}
|
|||
|
|
function refreshClaimPresentation(boardId=null){
|
|||
|
|
const ids=boardId?[boardId]:[...new Set([...rendered.keys(),...staticRendered.keys()])];for(const id of ids){const board=rendered.get(id);if(board)applyClaimPresentationToBoard(board);const staticBoard=staticRendered.get(id),claim=currentBoardClaim(id),own=claim?.playerId===currentPlayerId(),solved=metaState(id).solved;if(staticBoard){staticBoard.card.classList.toggle('claimed-other',Boolean(claim&&!own&&!solved));staticBoard.card.classList.toggle('claimed-own',Boolean(claim&&own&&!solved));staticBoard.card.dataset.claimLabel=claim&&!solved?(own?'占有中':`${claim.playerName||'他のプレイヤー'}がプレイ中`):'';staticBoard.card.title=claim&&!own?`${claim.playerName||'他のプレイヤー'}がプレイ中`:''}}
|
|||
|
|
}
|
|||
|
|
function applyClaim(raw){if(!raw?.boardId)return;boardClaims.set(raw.boardId,{...raw,expiresAt:Number(raw.expiresAt)||trustedNow()});if(raw.playerId===currentPlayerId())realtimeOwnClaimBoardId=raw.boardId;refreshClaimPresentation(raw.boardId)}
|
|||
|
|
function removeClaim(boardId,reason='released'){const claim=boardClaims.get(boardId);boardClaims.delete(boardId);if(realtimeOwnClaimBoardId===boardId)realtimeOwnClaimBoardId=null;refreshClaimPresentation(boardId);const board=rendered.get(boardId);if(claim?.playerId===currentPlayerId()&&board?.drawing?.pointerId!=null&&reason!=='cleared'){cancelPointerGestures();toast('盤面の占有期限が切れました。')}}
|
|||
|
|
function applyRealtimeSnapshot(message){
|
|||
|
|
const seenPlayers=new Set();for(const player of message.players||[]){seenPlayers.add(player.presenceId);applyRemotePlayer(player)}for(const id of[...remotePlayers.keys()])if(!seenPlayers.has(id))remotePlayers.delete(id);
|
|||
|
|
const seenClaims=new Set();for(const claim of message.claims||[]){seenClaims.add(claim.boardId);applyClaim(claim)}for(const id of[...boardClaims.keys()])if(!seenClaims.has(id))removeClaim(id,'viewport');for(const reaction of message.reactions||[])applyRealtimeReaction(reaction);schedulePresenceRender(true);refreshClaimPresentation();
|
|||
|
|
}
|
|||
|
|
function settleRealtimeClaimResult(message){const pending=realtimeClaimRequests.get(message.requestId);if(!pending)return;if(pending.timer)clearTimeout(pending.timer);realtimeClaimRequests.delete(message.requestId);if(message.claim)applyClaim(message.claim);pending.resolve(message.ok===true)}
|
|||
|
|
function handleRealtimeMessage(message){
|
|||
|
|
if(!message||typeof message!=='object')return;
|
|||
|
|
if(message.type==='ready'){realtimeReady=true;realtimePresenceId=message.presenceId;realtimeClaimTtlMs=Math.max(1000,Number(message.claimTtlMs)||realtimeClaimTtlMs);serverClockOffset=Number.isFinite(message.serverTime)?message.serverTime-Date.now():serverClockOffset;scheduleRealtimeViewport(true);if(realtimePendingCursor)realtimeSend({type:'cursor',...realtimePendingCursor});scheduleRealtimeHeartbeat();return}
|
|||
|
|
if(message.type==='snapshot')return applyRealtimeSnapshot(message);
|
|||
|
|
if(message.type==='cursor')return applyRemotePlayer(message);
|
|||
|
|
if(message.type==='player-left'){remotePlayers.delete(message.presenceId);schedulePresenceRender(true);scheduleMinimap(true);return}
|
|||
|
|
if(message.type==='claim')return applyClaim(message.claim);
|
|||
|
|
if(message.type==='claim-release')return removeClaim(message.boardId,message.reason);
|
|||
|
|
if(message.type==='claim-result')return settleRealtimeClaimResult(message);
|
|||
|
|
if(message.type==='reaction')return applyRealtimeReaction(message.reaction);
|
|||
|
|
if(message.type==='player-profile'){for(const player of remotePlayers.values())if(player.playerId===message.playerId)player.name=String(message.name||player.name).slice(0,24);for(const claim of boardClaims.values())if(claim.playerId===message.playerId)claim.playerName=String(message.name||claim.playerName).slice(0,24);refreshClaimPresentation();schedulePresenceRender(true);return}
|
|||
|
|
if(message.type==='board-cleared'&&message.event){const revision=Number(message.event.revision)||0;if(revision>(data.worldFeedRevision||0)){data.worldFeedRevision=revision;markGlobalDirty(false);addClearFeedEvent(message.event)}removeClaim(message.event.id,'cleared');void pullCloudWorld();}
|
|||
|
|
}
|
|||
|
|
function rejectRealtimeClaims(){for(const pending of realtimeClaimRequests.values()){clearTimeout(pending.timer);pending.resolve(false)}realtimeClaimRequests.clear()}
|
|||
|
|
function scheduleRealtimeReconnect(delay=1500){clearTimeout(realtimeReconnectTimer);if(!cloudAvailable||!data.cloudProfile||document.visibilityState==='hidden')return;realtimeReconnectTimer=setTimeout(connectRealtime,delay)}
|
|||
|
|
function connectRealtime(){
|
|||
|
|
clearTimeout(realtimeReconnectTimer);realtimeReconnectTimer=0;if(!cloudApiEnabled||!cloudAvailable||!data.cloudProfile||typeof WebSocket==='undefined')return false;if(realtimeSocket&&[WebSocket.OPEN,WebSocket.CONNECTING].includes(realtimeSocket.readyState))return true;
|
|||
|
|
const socket=new WebSocket(realtimeWebSocketUrl());realtimeSocket=socket;realtimeReady=false;
|
|||
|
|
socket.onopen=()=>{if(socket!==realtimeSocket)return;socket.send(JSON.stringify({type:'hello',playerId:data.cloudProfile.playerId,token:data.cloudProfile.token}))};
|
|||
|
|
socket.onmessage=event=>{if(socket!==realtimeSocket)return;try{handleRealtimeMessage(JSON.parse(event.data))}catch(error){console.warn('BEND FIELD: realtime message failed',error)}};
|
|||
|
|
socket.onerror=()=>{};socket.onclose=()=>{if(socket!==realtimeSocket)return;clearTimeout(realtimeHeartbeatTimer);realtimeHeartbeatTimer=0;realtimeReady=false;realtimePresenceId=null;realtimeSocket=null;remotePlayers.clear();realtimeReactions.clear();reactionDirty=true;rejectRealtimeClaims();schedulePresenceRender(true);scheduleReactionRender(true);scheduleMinimap(true);scheduleRealtimeReconnect()};return true;
|
|||
|
|
}
|
|||
|
|
function requestBoardClaim(boardId){
|
|||
|
|
if(metaState(boardId).solved)return Promise.resolve(false);const existing=currentBoardClaim(boardId);if(existing?.playerId===currentPlayerId()){touchBoardClaim(boardId,true);return Promise.resolve(true)}if(existing){toast(`${existing.playerName||'他のプレイヤー'}がプレイ中です。`);return Promise.resolve(false)}
|
|||
|
|
if(!cloudApiEnabled||!cloudAvailable)return Promise.resolve(true);if(!realtimeReady){connectRealtime();toast('共有接続を確認しています。');return Promise.resolve(false)}
|
|||
|
|
const requestId=`${sessionId}:${++realtimeRequestSequence}`,promise=new Promise(resolve=>{const timer=setTimeout(()=>{realtimeClaimRequests.delete(requestId);resolve(false)},REALTIME_CLAIM_REQUEST_TIMEOUT);realtimeClaimRequests.set(requestId,{resolve,timer,boardId})});
|
|||
|
|
if(!realtimeSend({type:'claim',requestId,boardId})){const pending=realtimeClaimRequests.get(requestId);clearTimeout(pending?.timer);realtimeClaimRequests.delete(requestId);return Promise.resolve(false)}return promise.then(ok=>{if(!ok){const claim=currentBoardClaim(boardId);toast(claim?`${claim.playerName||'他のプレイヤー'}がプレイ中です。`:'盤面を占有できませんでした。')}return ok});
|
|||
|
|
}
|
|||
|
|
async function ensureBoardClaimForInput(board){if(!board||metaState(board.id).solved)return false;return requestBoardClaim(board.id)}
|
|||
|
|
function touchBoardClaim(boardId,force=false){if(!boardClaimOwnedByMe(boardId)||!realtimeReady)return false;const now=Date.now();if(!force&&now-realtimeLastClaimTouchAt<REALTIME_CLAIM_TOUCH_INTERVAL)return true;realtimeLastClaimTouchAt=now;const sent=realtimeSend({type:'claim-touch',boardId});if(sent){const claim=boardClaims.get(boardId);if(claim)claim.expiresAt=trustedNow()+realtimeClaimTtlMs}return sent}
|
|||
|
|
function releaseOwnBoardClaim(boardId=realtimeOwnClaimBoardId){if(!boardId||!realtimeReady)return false;return realtimeSend({type:'release',boardId})}
|
|||
|
|
window.addEventListener('pointerdown',event=>realtimeHeldPointers.add(event.pointerId),{passive:true,capture:true});
|
|||
|
|
for(const type of['pointerup','pointercancel'])window.addEventListener(type,event=>realtimeHeldPointers.delete(event.pointerId),{passive:true,capture:true});
|
|||
|
|
window.addEventListener('pointermove',queueRealtimeCursor,{passive:true,capture:true});
|
|||
|
|
viewport.addEventListener('pointerleave',hideRealtimeCursor,{passive:true});
|
|||
|
|
|
|||
|
|
function sharedWorldGlobalForCloud(){return{schema:SAVE_SCHEMA,gameplayVersion:GAMEPLAY_DATA_VERSION,worldGeneration:WORLD_GENERATION,appVersion:APP_VERSION,generatorVersion:GENERATOR_VERSION,nextId:data.nextId,solved:data.solved||0,lastSolveAt:data.lastSolveAt||0,specialMechanicsSeen:[...new Set(data.specialMechanicsSeen||[])].filter(type=>['warp','lock','crossing'].includes(type)).sort(),quarantine:data.quarantine||{}}}
|
|||
|
|
function sharedWorldGlobalSignature(){return JSON.stringify(sharedWorldGlobalForCloud())}
|
|||
|
|
function mergeCloudPending(target,signal={}){
|
|||
|
|
for(const id of signal.metaIds||[]){target.deleted.delete(id);target.metaIds.add(id)}
|
|||
|
|
for(const id of signal.stateIds||[]){if(data?.states?.[id]?.solved!==true)continue;target.deleted.delete(id);target.stateIds.add(id)}
|
|||
|
|
for(const id of signal.deleted||[]){target.metaIds.delete(id);target.stateIds.delete(id);target.deleted.add(id)}
|
|||
|
|
target.globalChanged=target.globalChanged||signal.globalChanged===true;return target;
|
|||
|
|
}
|
|||
|
|
function takeCloudPendingBatch(source,limit=512){
|
|||
|
|
const batch=emptyCloudPending(),remainder=emptyCloudPending(),maximum=Math.max(1,Math.floor(limit)||1);let used=0;
|
|||
|
|
batch.globalChanged=source.globalChanged===true;
|
|||
|
|
for(const key of['metaIds','stateIds','deleted'])for(const id of source[key]||[])(used<maximum?batch:remainder)[key].add(id),used++;
|
|||
|
|
return{batch,remainder};
|
|||
|
|
}
|
|||
|
|
function cloudPendingHasWork(pending=cloudPushPending){return pending.globalChanged||pending.metaIds.size>0||pending.stateIds.size>0||pending.deleted.size>0}
|
|||
|
|
function cloudProfileIdentity(profile=data.cloudProfile){return profile?`${profile.playerId}.${profile.token}`:''}
|
|||
|
|
function scheduleCloudCheckpointRetry(){if(cloudCheckpointRetryTimer)return;cloudCheckpointRetryTimer=setTimeout(()=>{cloudCheckpointRetryTimer=0;if(hasPendingPersistence())void persistNow({skipCloud:true}).then(ok=>{if(!ok)scheduleCloudCheckpointRetry()})},1000)}
|
|||
|
|
function restoreCloudPushPending(){cloudPushPending=emptyCloudPending();mergeCloudPending(cloudPushPending,currentCloudPending())}
|
|||
|
|
function acknowledgeCloudPending(pending,metaRevs,stateRevs,changeSeq){
|
|||
|
|
for(const id of pending.metaIds)if(!dirtyMetaIds.has(id)&&data.metas[id]?.rev===metaRevs.get(id)){cloudJournalMetaIds.delete(id);cloudOutboxDeleteKeys.add(`meta:${id}`)}
|
|||
|
|
for(const id of pending.stateIds)if(!dirtyStateIds.has(id)&&data.states[id]?.rev===stateRevs.get(id)){cloudJournalStateIds.delete(id);cloudOutboxDeleteKeys.add(`state:${id}`)}
|
|||
|
|
for(const id of pending.deleted)if(!deletedBoardIds.has(id)&&!data.metas[id]){cloudJournalDeletedIds.delete(id);cloudOutboxDeleteKeys.add(`deleted:${id}`)}
|
|||
|
|
if(cloudJournalChangeSeq===changeSeq){cloudJournalGlobalChanged=false;cloudOutboxDeleteKeys.add('global')}
|
|||
|
|
data.cloudPending=currentCloudPending();restoreCloudPushPending();
|
|||
|
|
}
|
|||
|
|
function armCloudPush(delay=5000){clearTimeout(cloudPushTimer);cloudPushTimer=null;if(!cloudAvailable||!data.cloudProfile||cloudSyncing||!cloudPendingHasWork())return false;cloudPushTimer=setTimeout(()=>{cloudPushTimer=null;void pushCloudPending()},Math.max(0,delay));return true}
|
|||
|
|
async function fetchJson(url,options={},timeout=5000){const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),timeout);try{const response=await fetch(url,{...options,signal:controller.signal,headers:{'content-type':'application/json',...(options.headers||{})}}),body=await response.json().catch(()=>({}));if(!response.ok){const error=new Error(body.error||`HTTP ${response.status}`);error.status=response.status;error.body=body;throw error}return body}finally{clearTimeout(timer)}}
|
|||
|
|
function cloudAuthHeaders(){return data.cloudProfile?{authorization:`Bearer ${data.cloudProfile.playerId}.${data.cloudProfile.token}`}:{}}
|
|||
|
|
function applyPlayerEconomyEnvelope(raw){const player=raw?.player||raw;if(!player||!Array.isArray(player.purchases))return false;data.playerPurchases=normalizePlayerPurchases(player.purchases);data.playerEarnedScore=Number.isSafeInteger(player.earnedScore)&&player.earnedScore>=0?player.earnedScore:0;playerEconomyLoaded=true;invalidateEconomyCaches();markGlobalDirty(false);updateHud();if(openStoreBoardId)renderStorePanel();if(inventoryModal?.classList.contains('show'))renderInventoryPanel();return true}
|
|||
|
|
async function pullPlayerEconomy(){if(!cloudAvailable||!data.cloudProfile)return false;try{const result=await fetchJson('/api/player/state',{headers:cloudAuthHeaders()});serverClockOffset=Number.isFinite(result.serverTime)?result.serverTime-Date.now():serverClockOffset;applyPlayerEconomyEnvelope(result);await persistNow({skipCloud:true});return true}catch(error){playerEconomyLoaded=false;console.warn('BEND FIELD: player economy pull failed',error);return false}}
|
|||
|
|
async function buyPersonalStoreItem(boardId,itemId){const result=await fetchJson('/api/player/purchase',{method:'POST',headers:cloudAuthHeaders(),body:JSON.stringify({boardId,itemId})},10000);applyPlayerEconomyEnvelope(result);await persistNow({skipCloud:true});return result.purchase||null}
|
|||
|
|
function setCloudStatus(label,state=''){if(!cloudBtn)return;const name=currentPlayerName();cloudBtn.textContent=state==='saved'&&cloudAvailable?`共有 · ${name}`:label;cloudBtn.dataset.state=state;cloudBtn.title=cloudAvailable?`共有ワールド接続中:${name}。クリックで名前または同期コードを変更`:'共有ワールドを利用できません'}
|
|||
|
|
function addClearFeedEvent(event){
|
|||
|
|
if(!clearFeed||!event||typeof event.playerName!=='string')return;const item=document.createElement('div');item.className='clear-feed-item';const name=document.createElement('b'),details=document.createElement('span');name.textContent=`🎉 ${event.playerName}`;details.textContent=` (${Math.round(event.x||0)}, ${Math.round(event.y||0)}) · Lv.${Math.max(1,Math.round(event.level||1))}`;item.append(name,details);clearFeed.prepend(item);while(clearFeed.children.length>3)clearFeed.lastElementChild?.remove();setTimeout(()=>{item.classList.add('is-leaving');setTimeout(()=>item.remove(),380)},10000)
|
|||
|
|
}
|
|||
|
|
function applyCloudEnvelope(result,{initial=false}={}){
|
|||
|
|
let changed=false;if(result?.player?.name&&data.playerName!==result.player.name){data.playerName=String(result.player.name).slice(0,24);changed=true}
|
|||
|
|
const latest=Math.max(0,Number(result?.latestEventRevision)||0),events=Array.isArray(result?.clearEvents)?result.clearEvents:[];
|
|||
|
|
if(initial&&!(data.worldFeedRevision>0)){data.worldFeedRevision=latest;changed=true}else{for(const event of events)if((event.revision||0)>(data.worldFeedRevision||0))addClearFeedEvent(event);if(latest>(data.worldFeedRevision||0)){data.worldFeedRevision=latest;changed=true}}
|
|||
|
|
if(changed)markGlobalDirty(false);return changed;
|
|||
|
|
}
|
|||
|
|
async function createCloudProfile(){const result=await fetchJson('/api/cloud/session',{method:'POST',body:JSON.stringify({name:data.playerName||''})});serverClockOffset=result.serverTime-Date.now();data.cloudProfile={playerId:result.playerId,token:result.token};data.playerName=result.name||data.playerName||null;data.cloudRevision=0;markGlobalDirty(false);if(!await persistNow({skipCloud:true})){scheduleCloudCheckpointRetry();throw new Error('Shared-world profile could not be committed locally')}return data.cloudProfile}
|
|||
|
|
async function pullCloudWorld(force=false,sinceOverride=null,{initial=false,authoritative=false}={}){
|
|||
|
|
if(typeof fieldArchiveBusy!=='undefined'&&fieldArchiveBusy)return false;if(!cloudAvailable||!data.cloudProfile||cloudSyncing&&!force)return false;
|
|||
|
|
if(document.body?.classList?.contains('is-interacting')||[...rendered.values()].some(board=>board.drawing?.keyboardActive))return false;if(hasPendingPersistence()&&!await flushSave())return false;
|
|||
|
|
const profileIdentity=cloudProfileIdentity();cloudSyncing=true;setCloudStatus('共有取得中','syncing');let pulled=false;
|
|||
|
|
try{
|
|||
|
|
const previousRevision=sinceOverride??data.cloudRevision??0,remoteIds=new Set();let cursor=0,targetRevision=null,changed=false,fullSnapshot=false,firstPage=true,envelopeChanged=false,authoritativeWorld=authoritative===true;
|
|||
|
|
do{
|
|||
|
|
const query=`/api/cloud/pull?since=${previousRevision}&eventsSince=${data.worldFeedRevision||0}${cursor?`&cursor=${cursor}&at=${targetRevision}`:''}`,result=await fetchJson(query,{headers:cloudAuthHeaders()},30000);
|
|||
|
|
await waitForInteractionSettle();
|
|||
|
|
if(cloudProfileIdentity()!==profileIdentity)throw new Error('Shared-world profile changed while pulling');serverClockOffset=result.serverTime-Date.now();targetRevision??=result.revision||0;if(initial&&previousRevision===0&&targetRevision>0)authoritativeWorld=true;if(result.revision!==targetRevision)throw new Error('Shared world changed during paged pull');
|
|||
|
|
if(firstPage){envelopeChanged=applyCloudEnvelope(result,{initial})||envelopeChanged;firstPage=false}changed=result.changed===true;fullSnapshot=result.fullSnapshot===true;
|
|||
|
|
if(changed&&result.page){
|
|||
|
|
const external={...(result.page.global||{}),worldEpoch:data.worldEpoch,metas:{},states:{}};
|
|||
|
|
for(const[id,raw]of Object.entries(result.page.metas||{})){const meta=normalizeMeta(id,raw);if(!meta)throw new Error(`Shared metadata is invalid: ${id}`);external.metas[id]=meta;remoteIds.add(id)}
|
|||
|
|
for(const[id,raw]of Object.entries(result.page.states||{}))external.states[id]=normalizeState(raw);
|
|||
|
|
cloudApplyingRemote=true;try{mergeSnapshotIntoData(external,{finalize:false,authoritativeWorld});for(const id of result.page.deleted||[])if(data.metas[id]&&!cloudJournalMetaIds.has(id)&&!cloudJournalStateIds.has(id)){delete data.metas[id];delete data.states[id];markBoardDeleted(id);destroyBoard(rendered.get(id))}for(const id of Object.keys(external.metas))dirtyMetaIds.add(id);for(const id of Object.keys(external.states))dirtyStateIds.add(id)}finally{cloudApplyingRemote=false}
|
|||
|
|
}
|
|||
|
|
cursor=Number.isSafeInteger(result.nextCursor)?result.nextCursor:0;
|
|||
|
|
}while(cursor);
|
|||
|
|
data.cloudRevision=targetRevision||0;lastCloudWorldGlobalSignature=sharedWorldGlobalSignature();
|
|||
|
|
if(changed){cloudApplyingRemote=true;try{if(fullSnapshot)for(const id of Object.keys(data.metas))if(!remoteIds.has(id)&&(authoritativeWorld||!cloudJournalMetaIds.has(id)&&!cloudJournalStateIds.has(id))){clearSharedWorldJournalRow('meta',id);clearSharedWorldJournalRow('state',id);delete data.metas[id];delete data.states[id];markBoardDeleted(id);destroyBoard(rendered.get(id));destroyStaticBoard(staticRendered.get(id))}resolveMergedOverlaps();statsDirty=true;markGlobalDirty(false)}finally{cloudApplyingRemote=false}refreshWorldView({rebuild:true,syncConnections:true,persist:false});if(!await persistNow({skipCloud:true})){scheduleCloudCheckpointRetry();throw new Error('Shared-world pull could not be committed locally')}}else if(data.cloudRevision!==previousRevision||envelopeChanged){markGlobalDirty(false);if(!await persistNow({skipCloud:true})){scheduleCloudCheckpointRetry();throw new Error('Shared-world revision could not be committed locally')}}
|
|||
|
|
setCloudStatus('共有中','saved');pulled=true;return true;
|
|||
|
|
}catch(error){setCloudStatus('共有失敗','error');console.warn('BEND FIELD: shared-world pull failed',error);return false}
|
|||
|
|
finally{cloudSyncing=false;restoreCloudPushPending();if(cloudPendingHasWork())armCloudPush(pulled?250:5000)}
|
|||
|
|
}
|
|||
|
|
function scheduleCloudPush(signal={},delay=5000){mergeCloudPending(cloudPushPending,signal);armCloudPush(delay)}
|
|||
|
|
async function pushCloudPending(){
|
|||
|
|
clearTimeout(cloudPushTimer);cloudPushTimer=null;if(typeof fieldArchiveBusy!=='undefined'&&fieldArchiveBusy){armCloudPush(1000);return false}if(!cloudAvailable||!data.cloudProfile||cloudSyncing||!cloudPendingHasWork())return false;
|
|||
|
|
const{batch:pending,remainder}=takeCloudPendingBatch(cloudPushPending),profileIdentity=cloudProfileIdentity();cloudPushPending=remainder;
|
|||
|
|
const globalSignature=sharedWorldGlobalSignature();if(!pending.metaIds.size&&!pending.stateIds.size&&!pending.deleted.size&&pending.globalChanged&&globalSignature===lastCloudWorldGlobalSignature){cloudJournalGlobalChanged=false;cloudOutboxDeleteKeys.add('global');data.cloudPending=currentCloudPending();restoreCloudPushPending();if(!await persistNow({skipCloud:true})){scheduleCloudCheckpointRetry();return false}return true}
|
|||
|
|
cloudSyncing=true;setCloudStatus('共有送信中','syncing');let payload=null,sentMetaRevs=new Map(),sentStateRevs=new Map(),sentCloudChangeSeq=cloudJournalChangeSeq,retryDelay=250;
|
|||
|
|
try{
|
|||
|
|
const rows=await cloudRowsForStorage(pending.metaIds,pending.stateIds);payload={baseRevision:data.cloudRevision||0,global:sharedWorldGlobalForCloud(),metas:rows.metas,states:rows.states,deleted:[]};sentMetaRevs=new Map(payload.metas.map(meta=>[meta.id,meta.rev||0]));sentStateRevs=new Map(payload.states.map(row=>[row.id,row.value?.rev||0]));
|
|||
|
|
const result=await fetchJson('/api/cloud/push',{method:'POST',headers:cloudAuthHeaders(),body:JSON.stringify(payload)},30000);if(cloudProfileIdentity()!==profileIdentity){mergeCloudPending(cloudPushPending,pending);retryDelay=5000;setCloudStatus('共有保留','error');return false}
|
|||
|
|
serverClockOffset=result.serverTime-Date.now();data.cloudRevision=result.revision;applyCloudEnvelope(result);lastCloudWorldGlobalSignature=sharedWorldGlobalSignature();acknowledgeCloudPending(pending,sentMetaRevs,sentStateRevs,sentCloudChangeSeq);if(!await persistNow({skipCloud:true})){scheduleCloudCheckpointRetry();setCloudStatus('共有保留','error');return false}setCloudStatus('共有中','saved');return true;
|
|||
|
|
}catch(error){mergeCloudPending(cloudPushPending,pending);if(error.status===423&&payload){cloudSyncing=false;const pulled=await pullCloudWorld(true,0,{authoritative:true});retryDelay=pulled?500:5000;setCloudStatus(pulled?'占有期限切れを反映':'共有失敗',pulled?'saved':'error');toast('盤面の占有期限が切れたため、共有状態へ戻しました。')}else if(error.status===409&&payload){cloudSyncing=false;const pulled=await pullCloudWorld(true,payload.baseRevision,{authoritative:payload.baseRevision===0});retryDelay=pulled?250:5000}else if(error.status===400&&payload?.metas?.length){cloudSyncing=false;const pulled=await pullCloudWorld(true,0,{authoritative:true});retryDelay=pulled?500:5000;setCloudStatus(pulled?'共有競合を解消':'共有失敗',pulled?'saved':'error')}else{retryDelay=5000;setCloudStatus('共有失敗','error');console.warn('BEND FIELD: shared-world push failed',error)}return false}
|
|||
|
|
finally{cloudSyncing=false;if(cloudPendingHasWork())armCloudPush(retryDelay)}
|
|||
|
|
}
|
|||
|
|
function scheduleWorldPoll(delay=document.visibilityState==='visible'?6000:20000){clearTimeout(worldPollTimer);if(!cloudAvailable||!data.cloudProfile)return;worldPollTimer=setTimeout(async()=>{worldPollTimer=0;if(document.visibilityState==='visible')await pullCloudWorld();scheduleWorldPoll()},Math.max(1000,delay))}
|
|||
|
|
async function initCloudSync(){
|
|||
|
|
if(data.cloudSyncPaused){cloudAvailable=false;setCloudStatus('端末のみ','local');return false}if(!fieldIndexComplete)await completeV2IndexScan();if(cloudApiEnabled&&!cloudOutboxReady){cloudAvailable=false;setCloudStatus('共有保留','error');console.warn('BEND FIELD: shared-world sync deferred because the durable outbox could not be verified');return false}if(!cloudApiEnabled){cloudAvailable=false;setCloudStatus('端末のみ','local');return false}
|
|||
|
|
try{const status=await fetchJson('/api/cloud/status',{},2500);cloudAvailable=status.available===true;serverClockOffset=status.serverTime-Date.now()}catch(_){cloudAvailable=false;setCloudStatus('端末のみ','local');return false}
|
|||
|
|
if(!data.cloudProfile)await createCloudProfile();setCloudStatus('共有中','saved');await pullCloudWorld(false,null,{initial:true});await pullPlayerEconomy();
|
|||
|
|
if((data.cloudRevision||0)===0&&Object.keys(data.metas).length){for(const id of Object.keys(data.metas)){noteCloudRow('meta',id);if(data.states[id]?.solved)noteCloudRow('state',id)}cloudJournalGlobalChanged=true;data.cloudPending=currentCloudPending();markGlobalDirty(false);restoreCloudPushPending();await persistNow({skipCloud:true})}
|
|||
|
|
if(cloudPendingHasWork())await pushCloudPending();connectRealtime();scheduleRealtimeViewport(true);scheduleWorldPoll();return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function changePlayerNameFromTopUi(){
|
|||
|
|
const entered=prompt('プレイヤー名を入力してください(24文字まで)。',currentPlayerName());if(entered==null)return;
|
|||
|
|
const name=entered.replace(/[\u0000-\u001f\u007f]/g,'').replace(/\s+/g,' ').trim().slice(0,24);if(!name){toast('プレイヤー名を入力してください。');return}
|
|||
|
|
if(cloudAvailable&&data.cloudProfile){
|
|||
|
|
try{const result=await fetchJson('/api/cloud/profile',{method:'POST',headers:cloudAuthHeaders(),body:JSON.stringify({name})});data.playerName=result.name||name;setCloudStatus('共有中','saved')}
|
|||
|
|
catch(error){toast(`名前を変更できませんでした。 ${error.message}`);return}
|
|||
|
|
}else data.playerName=name;
|
|||
|
|
markGlobalDirty(false);await persistNow({skipCloud:true});updatePlayerNameUi();renderAll();schedulePresenceRender(true);toast(`プレイヤー名を「${data.playerName}」に変更しました。`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if(cloudBtn)cloudBtn.onclick=async()=>{
|
|||
|
|
if(data.cloudSyncPaused){if(!confirm('この復元フィールドを共有ワールドへ再接続しますか?'))return;data.cloudSyncPaused=false;markGlobalDirty(false);if(!await persistNow({skipCloud:true})){data.cloudSyncPaused=true;toast('共有ワールドを再開できませんでした。');return}await initCloudSync();return}
|
|||
|
|
if(cloudSyncing){toast('共有処理が終わってから変更してください。');return}if(!cloudAvailable){toast('共有ワールドを利用できません。');return}if(!data.cloudProfile)await createCloudProfile();
|
|||
|
|
const code=`${data.cloudProfile.playerId}.${data.cloudProfile.token}`,entered=prompt(`プレイヤー名を入力してください(24文字まで)。\n別端末の同期コードを使う場合は、そのコードを貼り付けてください。\n\n現在の同期コード:${code}`,currentPlayerName());if(!entered)return;
|
|||
|
|
const match=/^([a-f0-9]{16,64})\.([a-f0-9]{32,128})$/i.exec(entered.trim());if(match){data.cloudProfile={playerId:match[1],token:match[2]};data.cloudRevision=0;data.worldFeedRevision=0;data.playerPurchases=[];data.playerEarnedScore=0;playerEconomyLoaded=false;markGlobalDirty(false);if(!await persistNow({skipCloud:true})){scheduleCloudCheckpointRetry();toast('同期コードを保存できませんでした。');return}location.reload();return}
|
|||
|
|
const name=entered.replace(/[\u0000-\u001f\u007f]/g,'').replace(/\s+/g,' ').trim().slice(0,24);if(!name){toast('プレイヤー名を入力してください。');return}
|
|||
|
|
try{const result=await fetchJson('/api/cloud/profile',{method:'POST',headers:cloudAuthHeaders(),body:JSON.stringify({name})});data.playerName=result.name;markGlobalDirty(false);await persistNow({skipCloud:true});setCloudStatus('共有中','saved');toast(`プレイヤー名を「${result.name}」に変更しました。`)}catch(error){toast(`名前を変更できませんでした。 ${error.message}`)}
|
|||
|
|
};
|
|||
|
|
async function init(){
|
|||
|
|
deleteRetiredWorldData();
|
|||
|
|
data=attachWorldEpoch(await readInitialDataAsync(),startupWorldEpoch);const generatedInitialPlayerName=!data.playerName||!String(data.playerName).trim();if(generatedInitialPlayerName)data.playerName=createAutomaticPlayerName();seedRevisionClock(data);if(generatedInitialPlayerName)markGlobalDirty(false);
|
|||
|
|
for(const[id,tombstone]of recoveredDeletionTombstones)if(!data.metas[id]){deletedBoardIds.add(id);deletedBoardRevisions.set(id,tombstone.rev||0);deletedBoardAuthors.set(id,tombstone.revAuthor||'')}
|
|||
|
|
for(const id of startupRecoveredMetaIds)if(data.metas[id])dirtyMetaIds.add(id);
|
|||
|
|
for(const id of startupRecoveredStateIds)if(data.states[id])dirtyStateIds.add(id);
|
|||
|
|
for(const id of startupRecoveredDeletedIds)if(!data.metas[id]){deletedBoardIds.add(id);deletedBoardRevisions.set(id,nextRevision());deletedBoardAuthors.set(id,sessionId)}
|
|||
|
|
if(startupRecoveredMetaIds.size||startupRecoveredStateIds.size||startupRecoveredDeletedIds.size)markGlobalDirty(false);
|
|||
|
|
let storedCloudPending=startupCloudOutboxLoaded?startupCloudPending:null;
|
|||
|
|
if(!storedCloudPending&&cloudApiEnabled&&idbAvailable&&idbHealth==='ready')try{storedCloudPending=await withStartupTimeout(loadCloudOutboxFromDb(),IDB_STARTUP_TIMEOUT,'Cloud outbox startup timed out');cloudOutboxReady=true}catch(error){cloudOutboxReady=false;loadNotices.push('\u672a\u9001\u4fe1\u306e\u5909\u66f4\u3092\u78ba\u8a8d\u3067\u304d\u306a\u3044\u305f\u3081\u3001\u30af\u30e9\u30a6\u30c9\u540c\u671f\u3092\u4fdd\u7559\u3057\u307e\u3057\u305f\u3002');console.warn('BEND FIELD: cloud outbox startup fallback',error)}
|
|||
|
|
else if(startupCloudOutboxLoaded||!cloudApiEnabled)cloudOutboxReady=true;else cloudOutboxReady=false;
|
|||
|
|
storedCloudPending=storedCloudPending||normalizeCloudPending(null);
|
|||
|
|
const initialCloudPending=emptyCloudPending();mergeCloudPending(initialCloudPending,data.cloudPending);mergeCloudPending(initialCloudPending,storedCloudPending);
|
|||
|
|
restoreCloudPending({metaIds:[...initialCloudPending.metaIds],stateIds:[...initialCloudPending.stateIds],deleted:[...initialCloudPending.deleted],globalChanged:initialCloudPending.globalChanged});restoreCloudPushPending();
|
|||
|
|
applyUiSettings();syncCursorAppearance(data.cursorStyle||'default');document.body.dataset.debugItems=data.debugAllItems?'on':'off';
|
|||
|
|
stateStatSignatures.clear();stateEconomySignatures.clear();for(const[id,state]of Object.entries(data.states)){normalizedStateObjects.add(state);if(!state._summaryOnly){stateStatSignatures.set(id,stateStatSignature(state));stateEconomySignatures.set(id,stateEconomySignature(state))}}
|
|||
|
|
orphanPruneDirty=true;statsDirty=true;invalidateEconomyCaches();
|
|||
|
|
if(!fieldIndexComplete){cachedStats={solved:data.solved||0,score:data.score||0,earned:null};statsDirty=false}
|
|||
|
|
await ensureStart();
|
|||
|
|
const initialMeta=data.metas[data.selectedBoardId]||data.metas.B0||Object.values(data.metas)[0];
|
|||
|
|
try{
|
|||
|
|
if(initialMeta&&!initialMeta.puzzle)await hydrateMeta(initialMeta);
|
|||
|
|
if(!data.metas.B0?.puzzle&&data.metas.B0)await hydrateMeta(data.metas.B0);
|
|||
|
|
if(!data.metas.B0?.puzzle)throw new Error('\u539f\u70b9\u306e\u76e4\u9762\u3092\u8aad\u307f\u8fbc\u3081\u307e\u305b\u3093\u3002');
|
|||
|
|
if(startupWorldControl?.activationVerified===false)await validateActiveWorldReadiness(data.worldEpoch);
|
|||
|
|
}catch(error){
|
|||
|
|
if(startupWorldControl?.activationVerified===false&&idbAvailable){const restored=await rollbackUnverifiedWorld(await openWorldDb(),startupWorldControl).catch(()=>null);if(restored){lifecyclePersistenceSuppressed=true;rememberWorldEpoch(restored.activeEpoch);announceWorldReplacement(restored.activeEpoch);location.reload();return}}
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
refreshWorldView({rebuild:true,syncConnections:false,resumeTimer:true});
|
|||
|
|
requestAnimationFrame(()=>{if(!restoreSavedCamera())centerMeta(initialMeta||data.metas.B0,{select:false});scheduleMinimap(true);scheduleNoiseBackground(true)});
|
|||
|
|
document.body.classList.remove('loading');document.body.dataset.ready='true';document.body.dataset.generator='gate-procedural-v47';
|
|||
|
|
if(!fieldIndexComplete)setTimeout(()=>{void completeV2IndexScan().catch(error=>{console.warn('BEND FIELD: field index scan failed',error);showStatus(`\u30d5\u30a3\u30fc\u30eb\u30c9\u7d22\u5f15\u3092\u8aad\u307f\u8fbc\u3081\u307e\u305b\u3093\u3067\u3057\u305f\u3002 ${error?.message||error}`,{retry:true,fresh:false})})},0);
|
|||
|
|
drainWorldSignals();
|
|||
|
|
openHelp(true);
|
|||
|
|
if(loadNotices.length)showStatus(loadNotices.join(' '),{retry:false,fresh:true});
|
|||
|
|
if(hasPendingPersistence())void save(true);
|
|||
|
|
// Warm the generator process only; no surrounding puzzle is generated before a solved gate opens it.
|
|||
|
|
setTimeout(()=>{createPuzzleWorker();if(navigator.storage?.persist)void navigator.storage.persist().catch(()=>{})},0);
|
|||
|
|
setTimeout(()=>{void collectV2Garbage().catch(error=>console.warn('BEND FIELD: epoch cleanup deferred',error))},1500);
|
|||
|
|
setTimeout(()=>{
|
|||
|
|
reopenMissingGateExpansions();
|
|||
|
|
if(pendingExpansionCount())void repairExpansions().catch(handleExpansionError);
|
|||
|
|
},100);
|
|||
|
|
if(cloudApiEnabled)setTimeout(()=>{void initCloudSync().catch(error=>console.warn('BEND FIELD: deferred cloud sync failed',error))},1000);else setCloudStatus('端末のみ','local');
|
|||
|
|
}
|
|||
|
|
init().catch(error=>{
|
|||
|
|
console.error(error);document.body.classList.remove('loading');document.body.dataset.ready='error';document.body.dataset.error=error?.message||String(error);
|
|||
|
|
preserveRecovery(readCompactMirrorRaw()||safeLocalGet(storageKey),'Startup failed');
|
|||
|
|
showStatus(`\u30b2\u30fc\u30e0\u3092\u8d77\u52d5\u3067\u304d\u307e\u305b\u3093\uff1a${error?.message||error}`,{retry:true,fresh:true});toast('\u30b2\u30fc\u30e0\u3092\u8d77\u52d5\u3067\u304d\u307e\u305b\u3093\u3002',4000);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.addEventListener('click',event=>{const button=event.target.closest?.('button');if(button&&!button.disabled&&!button.closest('.board-actions')&&!button.classList.contains('store-buy')&&!button.classList.contains('line-store'))playSound('click')},{capture:true});
|