6600 lines
614 KiB
JavaScript
6600 lines
614 KiB
JavaScript
'use strict';
|
||
const BuildMeta=globalThis.BendBuildMeta;
|
||
if(!BuildMeta)throw new Error('BendBuildMeta is not loaded');
|
||
const SharedContracts=globalThis.BendSharedContracts;
|
||
if(!SharedContracts)throw new Error('BendSharedContracts is not loaded');
|
||
const CanonicalStoreCatalog=globalThis.BendStoreCatalog;
|
||
if(!Array.isArray(CanonicalStoreCatalog))throw new Error('BendStoreCatalog is not loaded');
|
||
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 FrameSchedulerApi=globalThis.BendFrameScheduler;
|
||
if(!FrameSchedulerApi)throw new Error('BendFrameScheduler is not loaded');
|
||
const DragSchedulerApi=globalThis.BendDragScheduler;
|
||
if(!DragSchedulerApi)throw new Error('BendDragScheduler is not loaded');
|
||
const InteractionStateApi=globalThis.BendInteractionState;
|
||
if(!InteractionStateApi)throw new Error('BendInteractionState is not loaded');
|
||
const interactionState=InteractionStateApi.createInteractionState();
|
||
function interactionActive(scope='any'){return interactionState.active(scope)}
|
||
const GestureCoordinatorApi=globalThis.BendGestureCoordinator;
|
||
if(!GestureCoordinatorApi)throw new Error('BendGestureCoordinator is not loaded');
|
||
const gestureCoordinator=GestureCoordinatorApi.createGestureCoordinator();
|
||
const CursorModelApi=globalThis.BendCursorModel;
|
||
if(!CursorModelApi)throw new Error('BendCursorModel 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,SAVE_SCHEMA,STORAGE_SCHEMA,IDB_LAYOUT_VERSION,FIELD_STORAGE_FORMAT,GAMEPLAY_DATA_VERSION,WORLD_GENERATION,GENERATOR_VERSION}=BuildMeta;
|
||
const CELL=43,CHUNK=5,UNIT=CELL*CHUNK,PAD=26;
|
||
const MAX_SCORE=Number.MAX_SAFE_INTEGER,MAX_SOLVES=1000000000,MAX_WORLD_COORD=1000000000;
|
||
const normalizeSpecialMechanics=SharedContracts.normalizeSpecialMechanics;
|
||
const appVersionLabel=`v${APP_VERSION}`,brandVersion=document.querySelector('.brand small');
|
||
if(brandVersion)brandVersion.textContent=appVersionLabel;
|
||
if(!document.title.endsWith(appVersionLabel))document.title=`${document.title.replace(/\s+v[\w.-]+$/,'').trim()} ${appVersionLabel}`;
|
||
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 MAX_RENDER_FPS=30,GLOBAL_FRAME_INTERVAL=1000/MAX_RENDER_FPS,AUXILIARY_FPS=30,AUXILIARY_FRAME_INTERVAL=1000/AUXILIARY_FPS,REACTION_TARGET_FPS=30,REACTION_FRAME_INTERVAL=1000/REACTION_TARGET_FPS,DRAG_TARGET_FPS=30,DRAG_FRAME_INTERVAL=1000/DRAG_TARGET_FPS,INTERACTION_FRAME_TOLERANCE_MS=1.25,DRAG_DISPLAY_WATCHDOG_MS=34,CAMERA_DISPLAY_WATCHDOG_MS=34,DRAG_MAX_POINTER_SAMPLES=12,DRAG_MAX_LOGICAL_SAMPLES_PER_FRAME=4,DRAG_MODEL_BUDGET_MS=.5,DRAG_MAX_LIVE_CATCHUP_CELLS=6,DRAG_MAX_RELEASE_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,PHP_REALTIME_CURSOR_INTERVAL=250,PHP_REALTIME_POLL_INTERVAL=500,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=0x9c37a5e1;
|
||
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,STORE_CHANCE=1/10,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=5.2,UNIQUE_SOLUTION_MIN_LEVEL=6;
|
||
const SPECIAL_CELL_MIN_LEVEL=5;
|
||
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 DEBUG_PURCHASE_MODE=location.pathname==='/debug-items'||location.pathname.endsWith('/debug-items')||new URLSearchParams(location.search).get('debug')==='items';
|
||
const TIME_ATTACK_COOLDOWN_MINUTES=Object.freeze({3:10,5:15,10:20});
|
||
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 COSMETIC_PRESENTATIONS=Object.freeze([
|
||
['line-color-cyan','スカイシアン','●','ラインカラー','澄んだ電気色のシアン。'],
|
||
['line-color-gold','ソーラーゴールド','●','ラインカラー','宝石のように明るい金色。'],['line-color-mint','ミントシグナル','●','ラインカラー','くっきりした信号色のグリーン。'],
|
||
['line-color-violet','アーケードバイオレット','●','ラインカラー','深みのある紫色。'],['line-color-tangerine','タンジェリン','●','ラインカラー','温かみのあるオレンジ。'],
|
||
['line-color-cobalt','コバルト','●','ラインカラー','彩度の高いブルー。'],['line-color-coral','ホットコーラル','●','ラインカラー','明るいコーラルレッド。'],
|
||
['line-color-aqua','アクアパルス','●','ラインカラー','涼しげな青緑色。'],['line-color-magenta','マゼンタポップ','●','ラインカラー','力強いマゼンタ。'],
|
||
['line-color-pearl','パールホワイト','●','プレミアムラインカラー','明るい真珠色のホワイト。'],['line-color-lime','ハイパーライム','●','プレミアムラインカラー','高エネルギーなライム色。'],
|
||
['line-color-amber','アンバーコア','●','プレミアムラインカラー','濃密な琥珀色。'],
|
||
['line-color-ice','アークティックアイス','●','プレミアムラインカラー','淡く結晶感のある水色。'],['line-color-lavender','ドリームラベンダー','●','プレミアムラインカラー','柔らかなラベンダー色。'],
|
||
['line-effect-aurora','オーロラ','≋','ショップ限定ラインカラー','選定されたオーロラ色が2秒ごとに切り替わり、線とゲートを彩ります。'],
|
||
['reaction-effect-giant','巨大','😀','リアクション','巨大な絵文字が落下し、盤面を揺らして背景にひびを入れます。'],
|
||
['reaction-effect-laser','レーザー','⚡','リアクション','大量のレーザーが交差するディスコ風エフェクト。'],['reaction-effect-orbit','オービット','🪐','リアクション','傾いた巨大な土星と星、周回する絵文字を表示します。'],
|
||
['reaction-effect-firework','花火','🎆','リアクション','打ち上げ後に多重の絵文字リングと小花火が開きます。'],
|
||
['reaction-effect-comet','彗星','☄','リアクション','遠方から絵文字が突入し、振動して大爆発します。']
|
||
].map(([id,name,icon,effectLabel,description])=>Object.freeze({id,name,icon,effectLabel,toast:name,description})));
|
||
const STORE_ITEM_BASE=Object.freeze([
|
||
Object.freeze({id:'score-lens',name:'ジェムレンズ',icon:'▦',effectLabel:'予想報酬表示 オン/オフ',toast:'予想報酬表示を切り替え',description:'未クリア盤面の予想報酬表示を切り替えます。'}),
|
||
...COSMETIC_PRESENTATIONS
|
||
]);
|
||
|
||
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
|
||
1F60C|relieved face
|
||
1F614|pensive face
|
||
1F62A|sleepy face
|
||
1F924|drooling face
|
||
1F634|sleeping face
|
||
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
|
||
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`;
|
||
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)));
|
||
return Object.freeze({
|
||
id:`cursor-face-${codeKey}`,name:`${emoji} カーソル`,cursorEmoji:emoji,icon:emoji,
|
||
effectLabel:'絵文字カーソル',toast:`${emoji} カーソル`,
|
||
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(' ');
|
||
function regionFlagEmoji(code){return String.fromCodePoint(...[...code].map(letter=>0x1f1e6+letter.charCodeAt(0)-65))}
|
||
const regionDisplayNames=typeof Intl?.DisplayNames==='function'?new Intl.DisplayNames(['ja'],{type:'region'}):null;
|
||
function regionFlagName(code){try{return regionDisplayNames?.of(code)||code}catch(_){return code}}
|
||
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:regionFlagName(code),emoji:regionFlagEmoji(code)})),
|
||
...[['gbeng','イングランド'],['gbsct','スコットランド'],['gbwls','ウェールズ']].map(([key,name])=>({key,name,emoji:subdivisionFlagEmoji(key)}))
|
||
].map(source=>Object.freeze({
|
||
id:`cursor-flag-${source.key}`,name:`${source.name}の国旗`,cursorEmoji:source.emoji,icon:source.emoji,flagAsset:`assets/flags/${emojiAssetKey(source.emoji)}.svg`,
|
||
effectLabel:'国旗カーソル',toast:source.emoji,description:''
|
||
})));
|
||
const STORE_PRESENTATION_ITEMS=Object.freeze([...STORE_ITEM_BASE,...YELLOW_FACE_CURSOR_ITEMS,...FLAG_CURSOR_ITEMS]);
|
||
const STORE_PRESENTATION_CATALOG=new Map(STORE_PRESENTATION_ITEMS.map(item=>[item.id,item]));
|
||
if(STORE_PRESENTATION_CATALOG.size!==CanonicalStoreCatalog.length||STORE_PRESENTATION_ITEMS.some(item=>!CanonicalStoreCatalog.some(contract=>contract.id===item.id)))throw new Error('ショップ表示データがカタログと一致しません');
|
||
const STORE_ITEMS=Object.freeze(CanonicalStoreCatalog.map(contract=>{
|
||
const presentation=STORE_PRESENTATION_CATALOG.get(contract.id);
|
||
if(!presentation)throw new Error(`ショップアイテム ${contract.id} の表示データがありません`);
|
||
return Object.freeze({...presentation,...contract});
|
||
}));
|
||
const CURSOR_ITEMS=Object.freeze(STORE_ITEMS.filter(item=>item.cursorStyle));
|
||
const STORE_ITEM_CATALOG=new Map(STORE_ITEMS.map(item=>[item.id,item]));
|
||
const cursorModel=CursorModelApi.createCursorModel(CURSOR_ITEMS);
|
||
const STORE_ITEM_IDS=new Set(STORE_ITEMS.map(item=>item.id));
|
||
const LINE_COLOR_ITEMS=Object.freeze(STORE_ITEMS.filter(item=>item.lineColor)),LINE_COLORS=Object.freeze(LINE_COLOR_ITEMS.map(item=>item.lineColor));
|
||
const AURORA_LINE_COLOR_ITEM_ID='line-effect-aurora',STARTER_LINE_COLOR_IDS=Object.freeze(LINE_COLOR_ITEMS.filter(item=>item.effectLabel==='ラインカラー').map(item=>item.id)),LINE_EFFECT_IDS=new Set(['aurora',...STORE_ITEMS.map(item=>item.lineEffect).filter(Boolean)]),REACTION_STYLE_IDS=new Set(['classic',...STORE_ITEMS.map(item=>item.reactionStyle).filter(Boolean)]);
|
||
function randomStarterLineColorId(){const value=new Uint32Array(1);try{globalThis.crypto?.getRandomValues?.(value)}catch(_){}if(!value[0])value[0]=(Math.random()*0xffffffff)>>>0;return STARTER_LINE_COLOR_IDS[value[0]%STARTER_LINE_COLOR_IDS.length]}
|
||
function validLineColorItemId(value){return typeof value==='string'&&Boolean(STORE_ITEM_CATALOG.get(value)?.lineColor)}
|
||
function validStarterLineColorId(value){return typeof value==='string'&&STARTER_LINE_COLOR_IDS.includes(value)}
|
||
function migratedLineColorStyle(source){const candidate=source?.lineEffectStyle==='aurora'?AURORA_LINE_COLOR_ITEM_ID:source?.lineColorStyle;return validLineColorItemId(candidate)?candidate:null}
|
||
function normalizeEquippedCosmeticsInPlace(target){if(!target)return target;target.lineColorStyle=migratedLineColorStyle(target)||(validStarterLineColorId(target.starterLineColor)?target.starterLineColor:STARTER_LINE_COLOR_IDS[0]);target.lineEffectStyle='none';target.reactionStyle=REACTION_STYLE_IDS.has(target.reactionStyle)?target.reactionStyle:'classic';target.lastReaction=target.lastReaction==='👉🏻'?'🤩':REACTION_EMOJIS.includes(target.lastReaction)?target.lastReaction:'👍';return target}
|
||
function starterLineColorForPlayer(playerId){if(typeof playerId!=='string'||!playerId)return randomStarterLineColorId();return STARTER_LINE_COLOR_IDS[hash32(AppLogic.stableHash(['starter-line-color',playerId]))%STARTER_LINE_COLOR_IDS.length]}
|
||
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`,
|
||
worldDbName=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:world`,
|
||
worldLockName=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:world`,
|
||
CHAIN_WINDOW=120000,SAVE_DELAY=90;
|
||
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'),boardHudLayer=document.querySelector('#boardHudLayer'),topbar=document.querySelector('#topbar'),overviewCanvas=document.querySelector('#overviewCanvas'),noiseCanvas=document.querySelector('#noiseCanvas'),
|
||
solvedCountEl=document.querySelector('#solvedCount'),scoreCountEl=document.querySelector('#scoreCount'),
|
||
worldCountEl=document.querySelector('#worldCount'),fpsCounter=document.querySelector('#fpsCounter'),playerNameBtn=document.querySelector('#playerNameBtn'),settingsBtn=document.querySelector('#settingsBtn'),
|
||
minimapCanvas=document.querySelector('#minimapCanvas'),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 runtimeConfig=globalThis.BendRuntimeConfig||Object.freeze({}),cloudApiEnabled=runtimeConfig.cloudApi===true,
|
||
cloudAppBaseUrl=(()=>{try{return new URL(runtimeConfig.appBaseUrl||'./',document.baseURI).href}catch(_){return document.baseURI}})(),
|
||
cloudApiBridgeUrl=(()=>{try{return runtimeConfig.apiBridgeUrl?new URL(runtimeConfig.apiBridgeUrl,cloudAppBaseUrl).href:''}catch(_){return''}})(),
|
||
cloudApiBaseCandidates=(()=>{const values=[];for(const candidate of[`${cloudAppBaseUrl}api/`,cloudApiBridgeUrl,(()=>{try{return new URL('/api/',location.href).href}catch(_){return'/api/'}})()])if(candidate&&!values.includes(candidate))values.push(candidate);return Object.freeze(values)})();
|
||
let cloudApiBaseUrl=cloudApiBaseCandidates[0]||'/api/',cloudApiPhpBridgeActive=false;
|
||
function cloudEndpointUrl(value,baseUrl=cloudApiBaseUrl){
|
||
if(typeof value!=='string'||!/^\/?api(?:\/|$)/.test(value))return value;
|
||
if(typeof cloudApiBridgeUrl!=='undefined'&&cloudApiBridgeUrl&&baseUrl===cloudApiBridgeUrl){
|
||
try{const source=new URL(value,'https://linkfield.invalid'),endpoint=new URL(cloudApiBridgeUrl);endpoint.searchParams.set('path',source.pathname);for(const[key,item]of source.searchParams)endpoint.searchParams.append(key,item);return endpoint.href}catch(_){return value}
|
||
}
|
||
const relative=value.replace(/^\/?api\/?/,'');try{return new URL(relative,baseUrl).href}catch(_){return`/api/${relative}`}
|
||
}
|
||
function cloudApiUsesPhpBridge(){return cloudApiPhpBridgeActive||Boolean(cloudApiBridgeUrl&&cloudApiBaseUrl===cloudApiBridgeUrl)}
|
||
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 sameDataValue(a,b){
|
||
if(Object.is(a,b))return true;
|
||
if(!a||!b||typeof a!=='object'||typeof b!=='object'||Array.isArray(a)!==Array.isArray(b))return false;
|
||
if(Array.isArray(a)){if(a.length!==b.length)return false;for(let index=0;index<a.length;index++)if(!sameDataValue(a[index],b[index]))return false;return true}
|
||
const aKeys=Object.keys(a),bKeys=Object.keys(b);if(aKeys.length!==bKeys.length)return false;
|
||
for(const key of aKeys)if(!Object.prototype.hasOwnProperty.call(b,key)||!sameDataValue(a[key],b[key]))return false;
|
||
return true;
|
||
}
|
||
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();
|
||
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);if(!uiSettings.lightweightRendering)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){
|
||
let sharedEndsAt=0;
|
||
if(isPlainObject(raw))for(const minutes of TIME_ATTACK_MINUTES){
|
||
const value=Number(raw[minutes]);
|
||
if(Number.isFinite(value)&&value>sharedEndsAt)sharedEndsAt=value;
|
||
}
|
||
return Object.fromEntries(TIME_ATTACK_MINUTES.map(minutes=>[minutes,sharedEndsAt]));
|
||
}
|
||
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){
|
||
return SharedContracts.normalizePlayerPurchases(raw,{resolveItem:storeItem,maxPaidCost:MAX_SCORE,defaultBuyer:DEFAULT_PLAYER_NAME});
|
||
}
|
||
function defaultData(){const starterLineColor=randomStarterLineColorId();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,starterLineColor,lineColorStyle:starterLineColor,lineEffectStyle:'none',reactionStyle:'classic',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),lineEffect:LINE_EFFECT_IDS.has(raw.lineEffect)?raw.lineEffect:null,ownerId:typeof raw.ownerId==='string'&&/^[a-f0-9]{16,64}$/i.test(raw.ownerId)?raw.ownerId.toLowerCase():null};
|
||
}
|
||
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,gates=[]){
|
||
const result={crossings:[],warps:[],locks:[],internalGates:[]},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});
|
||
}
|
||
const usedGateIndexes=new Set();
|
||
for(const rawPair of Array.isArray(raw?.internalGates)?raw.internalGates:[]){
|
||
const a=Number.isInteger(rawPair?.a)?rawPair.a:Number.isInteger(rawPair?.[0])?rawPair[0]:-1,b=Number.isInteger(rawPair?.b)?rawPair.b:Number.isInteger(rawPair?.[1])?rawPair[1]:-1;
|
||
const ga=gates[a],gb=gates[b];if(a<0||b<0||a===b||!ga||!gb||usedGateIndexes.has(a)||usedGateIndexes.has(b))continue;
|
||
const ac=[ga[0],ga[1]],bc=[gb[0],gb[1]],ak=ckey(...ac),bk=ckey(...bc);if(manhattan(ac,bc)!==1||reserved.has(ak)||reserved.has(bk))continue;
|
||
const[dr,dc]=SIDE_D[ga[2]]||[],[br,bcDelta]=SIDE_D[gb[2]]||[];if(ac[0]+dr!==bc[0]||ac[1]+dc!==bc[1]||bc[0]+br!==ac[0]||bc[1]+bcDelta!==ac[1])continue;
|
||
usedGateIndexes.add(a);usedGateIndexes.add(b);reserved.add(ak);reserved.add(bk);result.internalGates.push({a,b});
|
||
}
|
||
return result;
|
||
}
|
||
function normalizeStore(raw,paths){
|
||
if(!isPlainObject(raw))return null;
|
||
const directCell=Array.isArray(raw.cell)&&raw.cell.length===2&&Number.isInteger(raw.cell[0])&&Number.isInteger(raw.cell[1])?[raw.cell[0],raw.cell[1]]:null,
|
||
legacyPath=Number.isInteger(raw.pathIndex)&&raw.pathIndex>=0&&raw.pathIndex<paths.length?paths[raw.pathIndex]:null;
|
||
if(!directCell&&!legacyPath?.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,itemIds=normalizeStoreItemIds(raw.itemIds);
|
||
return{owner:typeof raw.owner==='string'&&raw.owner.trim()?raw.owner.trim().slice(0,32):DEFAULT_PLAYER_NAME,pathIndex:legacyPath?raw.pathIndex:-1,cellIndex:legacyPath?Math.max(0,Math.min(legacyPath.cells.length-1,Number.isInteger(raw.cellIndex)?raw.cellIndex:Math.floor(legacyPath.cells.length/2))):-1,cell:directCell,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,(puzzle.specialCells?.internalGates||[]).flatMap(pair=>[pair?.a,pair?.b]).filter(Number.isInteger));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=!sameDataValue(numbers,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,gates),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:[],internalGates:[]},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');if(puzzle?.specialCells?.internalGates?.length)types.push('internalGate');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'&&cursorModel.item(raw.cursorStyle)?raw.cursorStyle.slice(0,64):'default';
|
||
clean.scoreLensEnabled=raw.scoreLensEnabled===true;
|
||
clean.debugAllItems=false;
|
||
const seen=new Set(normalizeSpecialMechanics(raw.specialMechanicsSeen));
|
||
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.starterLineColor=validStarterLineColorId(raw.starterLineColor)?raw.starterLineColor:clean.cloudProfile?starterLineColorForPlayer(clean.cloudProfile.playerId):clean.starterLineColor;
|
||
clean.lineColorStyle=migratedLineColorStyle(raw)||clean.starterLineColor;
|
||
clean.lineEffectStyle='none';
|
||
clean.reactionStyle=REACTION_STYLE_IDS.has(raw.reactionStyle)?raw.reactionStyle:'classic';
|
||
clean.lastReaction=raw.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','starterLineColor','lineColorStyle','lineEffectStyle','reactionStyle','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();
|
||
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,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,overviewAllowInteractionBuild=false,overviewInteractionLastBuild=0;
|
||
const minimapBase=document.createElement('canvas'),overviewBase=document.createElement('canvas');
|
||
let overviewCache=null;
|
||
const OVERVIEW_CACHE_OVERSCAN_PX=192,OVERVIEW_INTERACTION_REBUILD_INTERVAL=180;
|
||
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),p99:pick(.99),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:0,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=interactionActive()?perfNow():0;interactionLastFrame=0;interactionBestFrameGap=Infinity;interactionFrameOpportunities=0;interactionDroppedFrames=0;reactionLastMetricFrame=0;perfResetAt=perfNow()}
|
||
globalThis.BEND_PERF=Object.freeze({snapshot:perfSnapshot,reset:resetPerf,renderReactionSample:options=>renderReactionSample(options),warmEffectCache:(emoji,style)=>warmReactionGlyphCache(emoji,style),clearEffectCaches:()=>clearReactionEffectCaches()});
|
||
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()){if(fpsLastFrameAt&×tamp-fpsLastFrameAt<GLOBAL_FRAME_INTERVAL-1)return;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=interactionActive(),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>=28?'good':fpsLastValue>=20?'ok':'low'}
|
||
fpsFrameCount=0;fpsWindowStarted=now;
|
||
}
|
||
setInterval(refreshFpsCounter,500);
|
||
const reducedMotionQuery=globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')||null;
|
||
let 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 scheduleNoiseBackground(force=false){
|
||
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,wheelInteractionTimer=0,interactionSettleFrame=0;
|
||
let interactionDrawingBoard=null,interactionPendingClaimBoard=null,interactionStyledBoard=null,interactionLastActiveBoard=null;
|
||
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 activeCandidate=rendered.get(activeBoard)||null,
|
||
drawingBoard=interactionDrawingBoard?.drawing?.pointerId!=null&&interactionDrawingBoard.card?.isConnected?interactionDrawingBoard:activeCandidate?.drawing?.pointerId!=null?activeCandidate:null,
|
||
pendingClaimBoard=interactionPendingClaimBoard?.pendingClaimPointer&&interactionPendingClaimBoard.card?.isConnected?interactionPendingClaimBoard:activeCandidate?.pendingClaimPointer?activeCandidate:null;
|
||
interactionState.set('camera',pan?.id??(pinch?'pinch':null));
|
||
interactionState.set('drawing',drawingBoard?.drawing?.pointerId??null);
|
||
interactionState.set('claim',pendingClaimBoard?.pendingClaimPointer?.pointerId??null);
|
||
if(force===true)interactionState.set('wheel','wheel');else if(force===false)interactionState.clear('wheel');
|
||
const active=interactionActive();
|
||
interactionDrawingBoard=drawingBoard;interactionPendingClaimBoard=pendingClaimBoard;
|
||
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));
|
||
const styledDrawingBoard=drawingBoard&&!usesLightweightDragOverlay(drawingBoard)?drawingBoard:null;
|
||
const affectedBoards=new Set([interactionStyledBoard,styledDrawingBoard,interactionLastActiveBoard,activeCandidate,rendered.get(hudBoardId),drawingBoard,pendingClaimBoard]);
|
||
for(const board of affectedBoards){
|
||
if(!board?.card?.isConnected)continue;
|
||
board.card.classList.toggle('input-active',board===styledDrawingBoard);
|
||
setBoardHudVisibility(board,boardPlayHudVisible(board));
|
||
}
|
||
interactionStyledBoard=styledDrawingBoard;interactionLastActiveBoard=activeCandidate;
|
||
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(zoomDetailsDirty)updateZoomPresentation(true);
|
||
}
|
||
}
|
||
function scheduleInteractionSettlePresentation(){
|
||
if(interactionSettleFrame)return;
|
||
interactionSettleFrame=requestAnimationFrame(()=>{
|
||
interactionSettleFrame=0;
|
||
if(interactionActive()){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);interactionFrames=0;interactionSlowFrames=0}
|
||
interactionLastFrame=timestamp;
|
||
}
|
||
function pulseWheelInteraction(){
|
||
gestureCoordinator.claim('wheel','wheel',{replace:true});refreshInteractionState(true);observeInteractionFrame();clearTimeout(wheelInteractionTimer);wheelInteractionTimer=setTimeout(()=>{gestureCoordinator.release('wheel','wheel');refreshInteractionState(false);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 activeSoundContext(){
|
||
const Audio=window.AudioContext||window.webkitAudioContext;if(!Audio)return null;
|
||
soundContext=soundContext&&soundContext.state!=='closed'?soundContext:new Audio();if(soundContext.state==='suspended')void soundContext.resume();return soundContext;
|
||
}
|
||
function soundTone(frequency,duration=.08,{gain=.035,type='sine',slide=1,delay=0}={}){
|
||
if(!uiSettings.soundEnabled)return;
|
||
try{
|
||
const context=activeSoundContext();if(!context)return;const now=context.currentTime+delay,osc=context.createOscillator(),amp=context.createGain();
|
||
osc.type=type;osc.frequency.setValueAtTime(Math.max(40,frequency),now);osc.frequency.exponentialRampToValueAtTime(Math.max(40,frequency*slide),now+duration);
|
||
const outputGain=Math.min(.42,Math.max(.001,gain*SOUND_GAIN_MULTIPLIER));
|
||
amp.gain.setValueAtTime(.0001,now);amp.gain.exponentialRampToValueAtTime(outputGain,now+.006);amp.gain.exponentialRampToValueAtTime(.0001,now+duration);
|
||
osc.connect(amp).connect(context.destination);osc.start(now);osc.stop(now+duration+.025);
|
||
}catch(_){}
|
||
}
|
||
function soundNoise(duration=.045,{gain=.012,delay=0,highpass=500}={}){
|
||
if(!uiSettings.soundEnabled)return;
|
||
try{
|
||
const context=activeSoundContext();if(!context)return;const now=context.currentTime+delay,length=Math.max(1,Math.ceil(context.sampleRate*duration)),buffer=context.createBuffer(1,length,context.sampleRate),channel=buffer.getChannelData(0);
|
||
for(let index=0;index<length;index++)channel[index]=(Math.random()*2-1)*(1-index/length);
|
||
const source=context.createBufferSource(),filter=context.createBiquadFilter(),amp=context.createGain();filter.type='highpass';filter.frequency.value=highpass;
|
||
const outputGain=Math.min(.24,Math.max(.001,gain*SOUND_GAIN_MULTIPLIER));amp.gain.setValueAtTime(outputGain,now);amp.gain.exponentialRampToValueAtTime(.0001,now+duration);
|
||
source.buffer=buffer;source.connect(filter).connect(amp).connect(context.destination);source.start(now);source.stop(now+duration+.01);
|
||
}catch(_){}
|
||
}
|
||
function playSound(kind){
|
||
if(kind==='grab'){soundTone(175,.065,{gain:.035,type:'triangle',slide:1.28});soundTone(285,.055,{gain:.022,type:'sine',slide:.9,delay:.014});soundNoise(.026,{gain:.009,highpass:1200})}
|
||
else if(kind==='stretch'){const now=performance.now();if(now-lastStretchSoundAt<45)return;lastStretchSoundAt=now;soundTone(132+(now%37),.045,{gain:.018,type:'triangle',slide:1.11})}
|
||
else if(kind==='gate'){soundTone(190,.095,{gain:.043,type:'triangle',slide:1.85});soundTone(410,.09,{gain:.03,type:'sine',slide:1.32,delay:.032});soundTone(650,.075,{gain:.018,type:'square',slide:1.08,delay:.07});soundNoise(.04,{gain:.009,delay:.025,highpass:1500})}
|
||
else if(kind==='clear'){[[240,0],[320,.055],[480,.11],[720,.18]].forEach(([frequency,delay],index)=>soundTone(frequency,.16,{gain:.04-index*.004,type:index%2?'sine':'triangle',slide:1.35,delay}));soundNoise(.08,{gain:.012,delay:.15,highpass:2200})}
|
||
else if(kind==='buy'){soundTone(310,.075,{gain:.037,type:'square',slide:1.22});soundTone(465,.1,{gain:.033,type:'triangle',slide:1.35,delay:.045});soundTone(760,.08,{gain:.022,type:'sine',slide:.96,delay:.105})}
|
||
else if(kind==='reset'){soundTone(265,.1,{gain:.035,type:'triangle',slide:.55});soundTone(150,.13,{gain:.028,type:'sine',slide:.68,delay:.04});soundNoise(.065,{gain:.011,delay:.025,highpass:700})}
|
||
else if(kind==='remove'){soundTone(330,.075,{gain:.032,type:'sawtooth',slide:.42});soundTone(145,.09,{gain:.024,type:'triangle',slide:.72,delay:.035});soundNoise(.055,{gain:.014,highpass:900})}
|
||
else if(kind==='shop'){soundTone(220,.1,{gain:.031,type:'square',slide:1});soundTone(330,.12,{gain:.027,type:'triangle',slide:1.08,delay:.035});soundTone(660,.13,{gain:.021,type:'sine',slide:1.04,delay:.08})}
|
||
else if(kind==='warp'){soundTone(115,.16,{gain:.032,type:'sine',slide:3.4});soundTone(540,.12,{gain:.018,type:'triangle',slide:.58,delay:.035});soundNoise(.07,{gain:.009,delay:.02,highpass:1800})}
|
||
else if(kind==='key'){soundTone(520,.065,{gain:.027,type:'square',slide:1.16});soundTone(780,.09,{gain:.024,type:'sine',slide:1.12,delay:.04})}
|
||
else if(kind==='error'){soundTone(185,.11,{gain:.03,type:'sawtooth',slide:.86});soundTone(155,.12,{gain:.024,type:'square',slide:.9,delay:.055})}
|
||
else{soundTone(235,.05,{gain:.022,type:'triangle',slide:1.1});soundTone(355,.038,{gain:.012,type:'sine',slide:.95,delay:.018})}
|
||
}
|
||
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;
|
||
}
|
||
let statusRetryAction=null;
|
||
function showStatus(message,{retry=true,fresh=false,onRetry=null}={}){
|
||
statusMessage.textContent=message;statusPanel.hidden=false;statusRetryAction=retry?(typeof onRetry==='function'?onRetry:()=>location.reload()):null;
|
||
document.querySelector('#retryBtn').hidden=!retry;
|
||
const freshButton=document.querySelector('#freshBtn');if(freshButton)freshButton.hidden=!fresh;
|
||
}
|
||
function hideStatus(){statusPanel.hidden=true;statusRetryAction=null}
|
||
async function runStatusRetry(){
|
||
const action=statusRetryAction;if(typeof action!=='function')return;
|
||
const button=document.querySelector('#retryBtn');if(button)button.disabled=true;
|
||
try{await action()}catch(error){console.warn('LinkField: retry failed',error);showStatus(`再接続できませんでした。 ${error?.message||error}`,{retry:true,fresh:false,onRetry:()=>location.reload()})}
|
||
finally{if(button)button.disabled=false}
|
||
}
|
||
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(interactionActive('worker')){perfCount('workerResultsDeferredDuringInteraction');waitForInteractionSettle(null,'worker').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){
|
||
const purchases=state?.store?.purchases||[];let spent=0,hash=2166136261;
|
||
for(const purchase of purchases){
|
||
const item=storeItem(purchase?.id),paid=item?(purchase.paidCost??item.cost):0,boughtAt=purchase?.boughtAt||0,text=`${purchase?.id||''}\0${paid}\0${boughtAt}\0`;
|
||
spent+=paid;for(let index=0;index<text.length;index++)hash=Math.imul(hash^text.charCodeAt(index),16777619)>>>0;
|
||
}
|
||
return{count:purchases.length,spent,hash};
|
||
}
|
||
function sameEconomySignature(a,b){return a===b||Boolean(a&&b&&a.count===b.count&&a.spent===b.spent&&a.hash===b.hash)}
|
||
function economySignatureSpend(signature){return signature?.spent||0}
|
||
function rememberStateSignatures(id,state){
|
||
const previousStat=stateStatSignatures.get(id),previousEconomy=stateEconomySignatures.get(id),stat=stateStatSignature(state),economy=stateEconomySignature(state),statChanged=previousStat!==stat,economyChanged=!sameEconomySignature(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&&!personalEconomyMode()){
|
||
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],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])cloudJournalStateIds.add(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==='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 ensureMetaState(id,{dirty=true}={}){
|
||
if(!data.states[id]||typeof data.states[id]!=='object'){
|
||
data.states[id]=normalizeState(null);normalizedStateObjects.add(data.states[id]);if(dirty)markStateDirty(id);return data.states[id];
|
||
}
|
||
const state=data.states[id];
|
||
if(normalizedStateObjects.has(state))return state;
|
||
replaceObjectContents(state,normalizeState(state));normalizedStateObjects.add(state);if(dirty)markStateDirty(id);return state;
|
||
}
|
||
function metaState(id){return data.states[id]&&typeof data.states[id]==='object'?data.states[id]:null}
|
||
function storeItem(id){return STORE_ITEM_CATALOG.get(id)||null}
|
||
function normalizeStoreItemIds(raw){
|
||
if(!Array.isArray(raw)||new Set(raw).size!==raw.length)return null;
|
||
const items=raw.map(storeItem);if(!items.every(Boolean))return null;
|
||
const cursors=items.filter(item=>item.cursorStyle).slice(0,6),others=items.filter(item=>!item.cursorStyle).slice(0,6);
|
||
return cursors.length===6&&others.length===6?[...cursors,...others].map(item=>item.id):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))),
|
||
fixedTools=otherPool.filter(item=>item.scoreLens).slice(0,1),
|
||
cosmeticPool=otherPool.filter(item=>!item.scoreLens),
|
||
selectedOthers=shuffle([...fixedTools,...cosmeticPool.slice(0,6-fixedTools.length)],rngFrom(hash32((seed>>>0)^0x6d2b79f5)));
|
||
return[...cursorPool.slice(0,6),...selectedOthers].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 personalEconomyMode(){return Boolean(cloudApiEnabled&&data.cloudProfile)}
|
||
function onlinePlayerEconomy(){return Boolean(personalEconomyMode()&&cloudAvailable&&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(personalEconomyMode())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 starterColorGrantCount(itemId=null){
|
||
const starter=validStarterLineColorId(data.starterLineColor)?data.starterLineColor:null;if(!starter)return 0;
|
||
if(itemId&&itemId!==starter)return 0;
|
||
return inventoryEntries(starter).length?0:1;
|
||
}
|
||
function inventoryCount(itemId=null){return inventoryEntries(itemId).length+starterColorGrantCount(itemId)}
|
||
function ownsStoreItem(itemId){return inventoryCount(itemId)>0}
|
||
function activeScoreLensCount(){return data.scoreLensEnabled===true?inventoryCount('score-lens'):0}
|
||
function spentScoreTotal(){
|
||
if(spentScoreCache!=null)return spentScoreCache;
|
||
let spent=0;if(personalEconomyMode())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=personalEconomyMode()?Math.max(0,Number(data.playerEarnedScore)||0):bonusEventTotal();data.bonusScore=personalEconomyMode()?0:earned;
|
||
for(const id of Object.keys(data.metas)){const st=metaState(id);if(st.solved)solved++;if(!personalEconomyMode())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=debugAllItemsEnabled()?'∞':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 preserveSolvedBoardState(solvedSource,baseState){
|
||
const base=solvedSource?._summaryOnly?baseState:solvedSource,preserved=deepClone(base||baseState||solvedSource||normalizeState(null));
|
||
preserved.solved=true;preserved.expanded=Boolean(solvedSource?.expanded||preserved.expanded);
|
||
for(const key of['solvedBy','solvedById','solvedAt','rewardIdentity','rewardCoefficient'])if(solvedSource?.[key]!=null)preserved[key]=deepClone(solvedSource[key]);
|
||
preserved.scoreAwarded=Math.max(Number(preserved.scoreAwarded)||0,Number(solvedSource?.scoreAwarded)||0);preserved.scoreVersion=SCORE_VERSION;
|
||
preserved.specialProgress=mergeSpecialProgress(solvedSource?.specialProgress,preserved.specialProgress);
|
||
if(solvedSource?.store){if(preserved.store)preserved.store.purchases=mergePurchases(preserved.store.purchases,solvedSource.store.purchases);else preserved.store=deepClone(solvedSource.store)}
|
||
delete preserved._summaryOnly;return preserved;
|
||
}
|
||
|
||
function sameMetaGeometry(a,b){return Boolean(a&&b&&a.x===b.x&&a.y===b.y&&a.seed===b.seed&&sameDataValue(a.chunks,b.chunks)&&sameDataValue(a.sealedSides||[],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=normalizeSpecialMechanics(external.specialMechanicsSeen);
|
||
if(isPlainObject(external.quarantine))data.quarantine=deepClone(external.quarantine);
|
||
statsDirty=true;
|
||
}
|
||
function mergeGlobalFields(external){
|
||
if(!external)return;
|
||
if(Number.isSafeInteger(external.nextId)&&external.nextId>0)data.nextId=Math.max(data.nextId||1,external.nextId);
|
||
if(Number.isSafeInteger(external.solved)&&external.solved>=0)data.solved=Math.max(data.solved||0,external.solved);
|
||
if(Number.isFinite(external.lastSolveAt)&&external.lastSolveAt>=0)data.lastSolveAt=Math.max(data.lastSolveAt||0,external.lastSolveAt);
|
||
if(Array.isArray(external.specialMechanicsSeen))data.specialMechanicsSeen=normalizeSpecialMechanics([...(data.specialMechanicsSeen||[]),...external.specialMechanicsSeen]);
|
||
if(isPlainObject(external.quarantine)){
|
||
const merged=deepClone(data.quarantine||{});for(const[id,value]of Object.entries(external.quarantine)){const current=merged[id];if(!current||(value?.failedAt||0)>=(current?.failedAt||0))merged[id]=deepClone(value)}data.quarantine=merged;
|
||
}
|
||
statsDirty=true;
|
||
}
|
||
function mergeSnapshotIntoData(external,{finalize=true,authoritativeWorld=false}={}){
|
||
if(!external||!isPlainObject(external.metas))return[];
|
||
const added=[],authoritativeCompatibleStateIds=authoritativeWorld?new Set():null;
|
||
for(const[id,incoming]of Object.entries(external.metas)){
|
||
lastRevision=Math.max(lastRevision,incoming.rev||0);const current=data.metas[id];
|
||
if(authoritativeWorld){
|
||
const compatible=!current||sameMetaGeometry(current,incoming);if(compatible)authoritativeCompatibleStateIds.add(id);
|
||
clearSharedWorldJournalRow('meta',id);
|
||
if(!current){data.metas[id]=incoming;added.push(id)}
|
||
else if(compatible)Object.assign(current,incoming);
|
||
else{destroyBoard(rendered.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){
|
||
const compatible=authoritativeCompatibleStateIds.has(id),retainLocalSolve=compatible&¤t?.solved===true&&incoming?.solved!==true,
|
||
retainClaimedPending=compatible&¤t?.solved!==true&&incoming?.solved!==true&&cloudJournalStateIds.has(id)&&typeof boardClaimOwnedByMe==='function'&&boardClaimOwnedByMe(id);
|
||
if(retainLocalSolve){noteCloudRow('state',id);merged=deepClone(current)}
|
||
else if(retainClaimedPending)merged=deepClone(current);
|
||
else{clearSharedWorldJournalRow('state',id);merged=deepClone(incoming)}
|
||
}else merged=mergeBoardStates(current,incoming);
|
||
if(!current||!sameDataValue(merged,current)){
|
||
if(current&&!authoritativeWorld){merged.rev=nextRevision();merged.revAuthor=sessionId}
|
||
data.states[id]=merged;normalizedStateObjects.add(merged);if(current)markStateDirty(id);if(merged.solved===true)removeClaim(id,'cleared');
|
||
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,
|
||
starterLineColor:validStarterLineColorId(source.starterLineColor)?source.starterLineColor:STARTER_LINE_COLOR_IDS[0],lineColorStyle:migratedLineColorStyle(source)||(validStarterLineColorId(source.starterLineColor)?source.starterLineColor:STARTER_LINE_COLOR_IDS[0]),lineEffectStyle:'none',reactionStyle:REACTION_STYLE_IDS.has(source.reactionStyle)?source.reactionStyle:'classic',
|
||
lastReaction:source.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:false,
|
||
specialMechanicsSeen:normalizeSpecialMechanics(source.specialMechanicsSeen),
|
||
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.playerEarnedScore=Math.max(Number(current.playerEarnedScore)||0,Number(incoming.playerEarnedScore)||0);
|
||
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=normalizeSpecialMechanics([...(current.specialMechanicsSeen||[]),...(incoming.specialMechanicsSeen||[])]);
|
||
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=deepClone(data.bonusEvents||{});
|
||
for(const key of['worldEpoch','globalRev','globalRevAuthor','gameplayVersion','quarantine','bonusEvents','clockFloor','cloudProfile','cloudRevision','cloudSyncPaused','playerName','playerPurchases','playerEarnedScore','starterLineColor','lineColorStyle','lineEffectStyle','reactionStyle','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]);
|
||
normalizeEquippedCosmeticsInPlace(data);data.debugAllItems=false;data.bonusScore=bonusEventTotal(data.bonusEvents);lastRevision=Math.max(lastRevision,data.globalRev||0,data.clockFloor?data.clockFloor*1000:0);
|
||
if(!sameDataValue(priorBonuses,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=Array.isArray(store?.cell)?[store.cell[0],store.cell[1]]:null;
|
||
if(!storeCell&&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,cell:Array.isArray(shop.cell)?[...shop.cell]:null,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&&interactionActive('persistence')){perfCount('savesDeferredDuringInteraction');await waitForInteractionSettle(null,'persistence')}
|
||
if(interactionActive('persistence'))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);
|
||
if(!skipCloud)scheduleCloudPush({metaIds,stateIds,deleted:[...committedDeleted],globalChanged});
|
||
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&&interactionActive('persistence')){perfCount('savesDeferredDuringInteraction');await waitForInteractionSettle(null,'persistence')}
|
||
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(interactionActive('persistence')){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 normalizePlayerNameInput(value){return String(value??'').replace(/[\u0000-\u001f\u007f]/g,'').replace(/\s+/g,' ').trim().slice(0,24)}
|
||
async function commitPlayerProfileName(value){
|
||
const requested=normalizePlayerNameInput(value);if(!requested)throw Object.assign(new Error('プレイヤー名を入力してください。'),{code:'INVALID_PLAYER_NAME'});
|
||
let committed=requested;
|
||
if(cloudAvailable&&data.cloudProfile){
|
||
const result=await fetchJson('/api/cloud/profile',{method:'POST',headers:cloudAuthHeaders(),body:JSON.stringify({name:requested})});
|
||
committed=normalizePlayerNameInput(result.name)||requested;setCloudStatus();
|
||
}
|
||
data.playerName=committed;markGlobalDirty(false);
|
||
if(!await persistNow({skipCloud:true}))throw new Error('プレイヤー名を端末へ保存できませんでした。');
|
||
updatePlayerNameUi();renderAll();schedulePresenceRender(true);return committed;
|
||
}
|
||
function updateHud(){pruneAndCount();worldCountEl.textContent=Object.keys(data.metas).length;updatePlayerNameUi();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 puzzle=puzzleOf(meta),gate=puzzle.g[gateIndex],internal=(puzzle.specialCells?.internalGates||[]).some(pair=>pair?.a===gateIndex||pair?.b===gateIndex);return Boolean(gate&&(internal||!(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`);
|
||
return meta.puzzle
|
||
}
|
||
function repairPuzzleDifficulty(meta,{dirty=true}={}){
|
||
const puzzle=puzzleOf(meta);if(puzzle.difficulty==null)puzzle.difficulty=solverDifficulty(puzzle,meta.targetLevel??meta.level??1);
|
||
if(meta.level===puzzle.difficulty)return false;meta.level=puzzle.difficulty;if(dirty)markMetaDirty(meta.id);return true;
|
||
}
|
||
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`);
|
||
repairPuzzleDifficulty(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)||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],storedSummary=boardIndexSummaries.get(id),currentState=data.states[id],summary=target&¤tState?summarizeBoardV2(target,currentState,data.worldEpoch):storedSummary;
|
||
if(target&&summary){boardIndexSummaries.set(id,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}`);
|
||
const currentState=data.states[meta.id],preserveSolved=Boolean(currentState?.solved&&!loadedState.solved),resolvedState=preserveSolved?preserveSolvedBoardState(currentState,loadedState):loadedState;
|
||
if(preserveSolved){resolvedState.rev=nextRevision();resolvedState.revAuthor=sessionId}
|
||
boardIndexSummaries.set(meta.id,index);Object.assign(meta,loadedMeta);delete meta._summaryOnly;data.states[meta.id]=resolvedState;normalizedStateObjects.add(resolvedState);rememberStateSignatures(meta.id,resolvedState);if(preserveSolved)markStateDirty(meta.id);
|
||
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);ensureMetaState(id,{dirty:false});markStateDirty(id);rebuildOccupancy();
|
||
return meta
|
||
}
|
||
async function ensureStart(){
|
||
if(Object.keys(data.metas).length)return;
|
||
const chunks=[[0,0]];let 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(let gateIndex=0;gateIndex<p.g.length;gateIndex++){if((p.specialCells?.internalGates||[]).some(pair=>pair?.a===gateIndex||pair?.b===gateIndex))continue;const g=p.g[gateIndex],[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);if((p.specialCells?.internalGates||[]).some(pair=>pair?.a===gi||pair?.b===gi)){adjacencyCache.set(cacheKey,null);return null}const 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(interactionActive('world'))return waitForInteractionSettle(undefined,'world').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',scope='any'){
|
||
if(!interactionActive(scope))return Promise.resolve();
|
||
if(counter)perfCount(counter);
|
||
return new Promise(resolve=>{const unsubscribe=interactionState.subscribe(()=>{if(!interactionActive(scope)){unsubscribe();resolve()}})});
|
||
}
|
||
|
||
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 direct=Array.isArray(store?.cell)?store.cell:null,path=store&&metaState(meta.id).paths[store.pathIndex],cell=direct||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,direct=Array.isArray(store?.cell)?store.cell:null,path=store&&state.paths[store.pathIndex],cell=direct||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((p.specialCells?.internalGates||[]).some(pair=>pair?.a===gateIndex||pair?.b===gateIndex)||!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((p.specialCells?.internalGates||[]).some(pair=>pair?.a===gateIndex||pair?.b===gateIndex)||!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((p.specialCells?.internalGates||[]).some(pair=>pair?.a===gateIndex||pair?.b===gateIndex)||!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((p.specialCells?.internalGates||[]).some(pair=>pair?.a===gateIndex||pair?.b===gateIndex)||!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';
|
||
const internalSeen=new Set();for(const pair of special.internalGates||[]){const a=pair?.a,b=pair?.b,ga=puzzle.g[a],gb=puzzle.g[b];if(!Number.isInteger(a)||!Number.isInteger(b)||a===b||!ga||!gb||internalSeen.has(a)||internalSeen.has(b))return'盤面内ゲートが不正';const da=SIDE_D[ga[2]],db=SIDE_D[gb[2]];if(manhattan(ga,gb)!==1||ga[0]+da[0]!==gb[0]||ga[1]+da[1]!==gb[1]||gb[0]+db[0]!==ga[0]||gb[1]+db[1]!==ga[1])return'盤面内ゲートの向きが不正';internalSeen.add(a);internalSeen.add(b)}
|
||
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:[],internalGates:[]};
|
||
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=[];
|
||
if(!Array.isArray(p.specialCells.internalGates))p.specialCells.internalGates=[];
|
||
return p.specialCells;
|
||
}
|
||
function internalGateIndexes(p){
|
||
const indexes=[];for(const pair of specialCellSet(p).internalGates||[]){if(Number.isInteger(pair?.a))indexes.push(pair.a);if(Number.isInteger(pair?.b))indexes.push(pair.b)}return indexes;
|
||
}
|
||
function internalGateIndexSet(p){if(!p)return new Set();return p._internalGateIndexes||(p._internalGateIndexes=new Set(internalGateIndexes(p)))}
|
||
function isInternalGateIndex(p,index){return internalGateIndexSet(p).has(index)}
|
||
function internalGatePartner(p,index){for(const pair of specialCellSet(p).internalGates||[]){if(pair?.a===index)return pair.b;if(pair?.b===index)return pair.a}return null}
|
||
function invalidateSpecialCellCaches(p){if(p){delete p._warpMap;delete p._internalGateIndexes;delete p._gateCells}}
|
||
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))}
|
||
for(const pair of special.internalGates){const a=p.g?.[pair.a],b=p.g?.[pair.b];if(a)keys.add(ckey(a[0],a[1]));if(b)keys.add(ckey(b[0],b[1]))}
|
||
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:[],internalGates:[]};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 addInternalGateSpecial(p,rng,reserved){
|
||
const valid=cellSet(p),gateCells=new Set(p.g.map(g=>ckey(g[0],g[1]))),candidates=[],sideFor=(a,b)=>b[0]<a[0]?'N':b[0]>a[0]?'S':b[1]<a[1]?'W':'E';
|
||
p.solution.forEach((path,pathIndex)=>{
|
||
for(let cut=1;cut<path.cells.length-2;cut++){
|
||
const a=path.cells[cut],b=path.cells[cut+1],ak=ckey(...a),bk=ckey(...b);if(manhattan(a,b)!==1||reserved.has(ak)||reserved.has(bk)||gateCells.has(ak)||gateCells.has(bk))continue;
|
||
const separatesLock=specialCellSet(p).locks.some(lock=>{const keyIndex=path.cells.findIndex(cell=>sameCell(cell,lock.key)),doorIndex=path.cells.findIndex(cell=>sameCell(cell,lock.door));return keyIndex>=0&&doorIndex>=0&&(keyIndex<=cut)!==(doorIndex<=cut)});if(separatesLock)continue;
|
||
// Internal gates are rendered at cell centers, so even a cell on the outer row/column remains an in-board gate rather than a world-edge gate.
|
||
if(!valid.has(ak)||!valid.has(bk))continue;
|
||
candidates.push({pathIndex,cut,a:[...a],b:[...b],balance:Math.min(cut,path.cells.length-cut-2)});
|
||
}
|
||
});
|
||
if(!candidates.length)return false;
|
||
candidates.sort((a,b)=>b.balance-a.balance||rng()-.5);
|
||
const preferred=candidates.splice(Math.floor(rng()*Math.min(10,candidates.length)),1)[0],ordered=[preferred,...candidates];
|
||
for(const chosen of ordered){
|
||
const path=p.solution[chosen.pathIndex],aIndex=p.g.length,bIndex=p.g.length+1,sideA=sideFor(chosen.a,chosen.b),sideB=OPP[sideA],
|
||
prefix={...path,endGate:aIndex,cells:path.cells.slice(0,chosen.cut+1)},suffix={...path,startGate:bIndex,cells:path.cells.slice(chosen.cut+1)},special=specialCellSet(p);
|
||
p.g.push([chosen.a[0],chosen.a[1],sideA],[chosen.b[0],chosen.b[1],sideB]);p.solution.splice(chosen.pathIndex,1,prefix,suffix);special.internalGates.push({a:aIndex,b:bIndex});invalidateSpecialCellCaches(p);
|
||
const clueable=[prefix,suffix].every(segment=>{const analysis=turnAnalysis(segment,p);return analysis.count>0&&analysis.cells.length>0});
|
||
if(clueable){reserved.add(ckey(...chosen.a));reserved.add(ckey(...chosen.b));return true}
|
||
special.internalGates.pop();p.solution.splice(chosen.pathIndex,2,path);p.g.length=aIndex;invalidateSpecialCellCaches(p);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
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);
|
||
if(type==='internalGate')return addInternalGateSpecial(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+(special.internalGates?.length||0)*2;
|
||
}
|
||
function addSpecialCellPattern(sourcePuzzle,seed,level){
|
||
const scheduled=AppLogic.specialSchedule(level,sourcePuzzle.valid?.length||0,seed,data.specialMechanicsSeen||[],recentSpecialMechanicTypes()),
|
||
requiredTypes=[...scheduled.types];
|
||
if(!requiredTypes.length)return sourcePuzzle;
|
||
const schedule={...scheduled,types:requiredTypes,setCount:Math.max(scheduled.setCount||0,requiredTypes.length),maxCells:Math.max(scheduled.maxCells||0,requiredTypes.reduce((sum,type)=>sum+(type==='crossing'?1:2),0))},
|
||
puzzle=deepClone(sourcePuzzle),special=specialCellSet(puzzle),reserved=reservedSpecialKeys(puzzle);
|
||
special.crossings=[];special.warps=[];special.locks=[];special.internalGates=[];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:2,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);ensureMetaState(id,{dirty:false});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&&level<=3&&shape.length!==1)issue='low-level board must use one section';
|
||
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;
|
||
}
|
||
function canExpandSharedBoard(meta,state=meta?.id?metaState(meta.id):null){return Boolean(meta&&state?.solved&&(!cloudAvailable||!data.cloudProfile||state.solvedById===currentPlayerId()))}
|
||
async function prepareExpansionCandidate(meta){
|
||
if(!meta||data.metas[meta.id]!==meta||!canExpandSharedBoard(meta))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(!canExpandSharedBoard(meta,st)||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(!canExpandSharedBoard(meta,st)||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){return state?.solved&&state.solvedById===currentPlayerId()?0:Infinity}
|
||
function nextSharedExpansionRepairDelay(){return Object.values(data.metas).some(meta=>canExpandSharedBoard(meta)&&!metaState(meta.id).expanded)?0:null}
|
||
function hasSharedExpansionRepairAuthority(){return Object.values(data.metas).some(meta=>canExpandSharedBoard(meta)&&!metaState(meta.id).expanded)}
|
||
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 canExpandSharedBoard(meta,st)&&!st.expanded});
|
||
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})=>canExpandSharedBoard(source)).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(!canExpandSharedBoard(meta,st)||!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(canExpandSharedBoard(meta,st)&&!st.expanded)count++}
|
||
return count+closedVoidRepairCandidates().filter(({source})=>canExpandSharedBoard(source)).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],internal:isInternalGateIndex(p,i)}}
|
||
function outsidePoint(g){if(g?.internal)return null;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){const special=specialCellSet(p),internal=(special.internalGates||[]).flatMap(pair=>[pair?.a,pair?.b]).filter(Number.isInteger);return path?.detachedStart?detachedTurnAnalysis(path,p):AppLogic.analyzePathTurns(path,p.g,special.warps,includeEnd,internal)}
|
||
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)}
|
||
if(st.solved&&st.store){const obstacleCell=storeObstacleCell(meta),current=Array.isArray(st.store.cell)?st.store.cell:null;if(obstacleCell&&(!current||!sameCell(current,obstacleCell))){st.store.cell=obstacleCell;st.store.pathIndex=-1;st.store.cellIndex=-1;delete st.store.summaryCell;st.rev=nextRevision();markStateDirty(meta.id)}}
|
||
const solved=st.solved===true||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);return inherited??activeLineColorIndex()}
|
||
function pathColor(path,index=0){return LINE_COLORS[(path.colorIndex??index)%LINE_COLORS.length]}
|
||
function lineEffectClass(effect){return LINE_EFFECT_IDS.has(effect)?` line-effect-${effect}`:''}
|
||
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 startOutside=path.detachedStart?null:outsidePoint(gateObj(p,path.startGate)),points=[...(startOutside?[startOutside]:[]),...path.cells],offset=startOutside?1:0;
|
||
if(path.endGate!=null){const endOutside=outsidePoint(gateObj(p,path.endGate));if(endOutside)points.push(endOutside)}
|
||
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.internal?.5:g.side==='E'?1:g.side==='W'?0:.5))/CHUNK,member.y+(row+(g.internal?.5: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||interactionActive('overview'))return;
|
||
const step=timestamp=>{
|
||
if(!minimapFrame)return;
|
||
if(interactionActive('overview')){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 drawMapStores(context,metas,mapX,mapY,unitSize){
|
||
let count=0;context.save();context.lineJoin='round';
|
||
for(const meta of metas){const store=metaState(meta.id).store;if(!store)continue;const[xWorld,yWorld]=storePriceLocation(meta,store),x=mapX(xWorld),y=mapY(yWorld),radius=Math.max(3,Math.min(10,unitSize*.18));count++;
|
||
context.beginPath();context.moveTo(x,y-radius*1.45);context.lineTo(x+radius*1.2,y);context.lineTo(x,y+radius*1.45);context.lineTo(x-radius*1.2,y);context.closePath();context.fillStyle='#ff46c7';context.fill();context.lineWidth=Math.max(1,radius*.28);context.strokeStyle='#6ffcff';context.stroke();
|
||
context.beginPath();context.arc(x,y,radius*.34,0,Math.PI*2);context.fillStyle='#11151c';context.fill();
|
||
}
|
||
context.restore();return count;
|
||
}
|
||
function nearestStoreMetaAtWorldPoint(worldX,worldY,pixelRadius,worldPixelsPerChunk){
|
||
const radius=Math.max(.05,pixelRadius/Math.max(1,worldPixelsPerChunk));let best=null,bestDistance=radius*radius;
|
||
for(const meta of Object.values(data.metas||{})){const store=metaState(meta.id).store;if(!store)continue;const[x,y]=storePriceLocation(meta,store),distance=(x-worldX)**2+(y-worldY)**2;if(distance<=bestDistance){bestDistance=distance;best=meta}}
|
||
return best;
|
||
}
|
||
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),storeCount=drawMapStores(base,visibleMetas,mapX,mapY,scale);
|
||
minimapCache={revision:minimapWorldRevision,width,height,dpr,anchorX:centerX,anchorY:centerY,scale,overscanPixels,baseWidth,baseHeight,longSegments,storeCount};perfCount('minimapWorldBuilds');perfEnd('rebuildMinimapWorld',started);
|
||
}
|
||
function drawMinimap(){
|
||
if(interactionActive('overview')){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();
|
||
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;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 label='マップ。店舗マーカーをクリックするとショップを開きます。';
|
||
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||!gestureCoordinator.claim(event.pointerId,'minimap'))return;event.preventDefault();event.stopPropagation();const target=minimapWorldPoint(event),storeMeta=nearestStoreMetaAtWorldPoint(target.x,target.y,14,target.scale);
|
||
if(storeMeta){gestureCoordinator.release(event.pointerId,'minimap');playSound('shop');centerMeta(storeMeta,{preserveScale:true,select:false});openStoreMeta(storeMeta);minimapCanvas.focus({preventScroll:true});return}
|
||
minimapPointerState={id:event.pointerId,startClientX:event.clientX,startClientY:event.clientY,targetX:target.x,targetY:target.y,scale:target.scale};
|
||
interactionState.set('minimap',event.pointerId);
|
||
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(_){}
|
||
gestureCoordinator.release(event.pointerId,'minimap');interactionState.clear('minimap');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 storeObstacleCell(meta){const obstacles=puzzleOf(meta).obstacles||[];if(!obstacles.length)return null;return[...obstacles[(hash32((meta.seed>>>0)^0x2fd51a37)>>>0)%obstacles.length]]}
|
||
function maybeOpenStore(meta,st,solveAward,roll=null){
|
||
const deterministicRoll=roll==null?(hash32(meta.seed^0x7f4a7c15)>>>0)/4294967296:roll,cell=storeObstacleCell(meta);
|
||
if(!st.solved||st.store||!cell||deterministicRoll>=STORE_CHANCE)return 0;
|
||
const bonus=0;
|
||
st.store={owner:currentPlayerName(),pathIndex:-1,cellIndex:-1,cell,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;if(g.internal)return[x,y];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,internal=false){
|
||
if(internal){const[dr,dc]=SIDE_D[side],tx=-dr,ty=dc,stem=10*scale,wing=5*scale,tip=[point[0]+dc*stem,point[1]+dr*stem],left=[point[0]+tx*wing,point[1]+ty*wing],right=[point[0]-tx*wing,point[1]-ty*wing];return`M ${left[0]} ${left[1]} L ${point[0]} ${point[1]} L ${right[0]} ${right[1]} M ${point[0]} ${point[1]} L ${tip[0]} ${tip[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,internal=false){
|
||
if(internal){const half=Math.round(CELL*.38);return{x:point[0]-half,y:point[1]-half,width:half*2,height:half*2}}
|
||
// 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;
|
||
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){
|
||
if(!b?.label||!boardHudLayer||!boardPlayHudVisible(b))return false;
|
||
const candidates=hudPlacementCandidates(b.meta),fallback={side:'N',dx:0,dy:0,priority:0},viewportRect=getViewportRect(true),boardRect=boardScreenRect(b),scale=Math.max(MIN_CAMERA_SCALE,cam.scale),margin=8,gap=10,
|
||
innerWidth=b.p.bounds.w*CELL,label=b.label;
|
||
label.hidden=false;label.classList.add('hud-visible');label.style.removeProperty('width');label.style.maxWidth=Math.max(120,viewportRect.width-margin*2)+'px';
|
||
const labelWidth=Math.min(viewportRect.width-margin*2,Math.max(1,label.offsetWidth||190)),labelHeight=Math.min(viewportRect.height-margin*2,Math.max(1,label.offsetHeight||34));
|
||
const anchorFor=placement=>{
|
||
const side=placement.side||'N',horizontal=side==='N'||side==='S',
|
||
x=horizontal?boardRect.left-viewportRect.left+(PAD+innerWidth/2)*scale:boardRect.left-viewportRect.left+(PAD+(placement.dx+(side==='E'?1:0))*UNIT)*scale,
|
||
y=horizontal?boardRect.top-viewportRect.top+(PAD+(side==='S'?b.p.bounds.h*CELL:0))*scale:boardRect.top-viewportRect.top+(PAD+(placement.dy+.5)*UNIT)*scale;
|
||
if(side==='N')return{left:x-labelWidth/2,top:y-gap-labelHeight};
|
||
if(side==='S')return{left:x-labelWidth/2,top:y+gap};
|
||
if(side==='W')return{left:x-gap-labelWidth,top:y-labelHeight/2};
|
||
return{left:x+gap,top:y-labelHeight/2};
|
||
};
|
||
const overflow=point=>Math.max(0,margin-point.left)+Math.max(0,point.left+labelWidth-(viewportRect.width-margin))+Math.max(0,margin-point.top)+Math.max(0,point.top+labelHeight-(viewportRect.height-margin));
|
||
const ranked=(candidates.length?candidates:[fallback]).map(candidate=>({candidate,point:anchorFor(candidate)}));ranked.sort((a,c)=>overflow(a.point)-overflow(c.point)||(a.candidate.priority||0)-(c.candidate.priority||0));
|
||
const selected=ranked[0],left=Math.max(margin,Math.min(selected.point.left,viewportRect.width-margin-labelWidth)),top=Math.max(margin,Math.min(selected.point.top,viewportRect.height-margin-labelHeight));
|
||
label.dataset.side=selected.candidate.side;label.style.left=left.toFixed(2)+'px';label.style.top=top.toFixed(2)+'px';label.style.transform='none';return true;
|
||
}
|
||
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 commitConnectedLineVisuals(b,pathIndex=null){
|
||
if(!b)return false;invalidateLineGraphCaches(b.id,pathIndex);if(b.card?.isConnected)renderBoard(b);if(Number.isInteger(pathIndex))queueLineWidthRefresh(b.id,pathIndex);else queueLineWidthRefresh(b.id);return true
|
||
}
|
||
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(interactionActive('overview'))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'})}
|
||
for(const pair of special.internalGates||[])for(const index of[pair.a,pair.b]){const gate=p.g?.[index];if(gate)map.set(ckey(gate[0],gate[1]),{type:'internal-gate',description:'盤面内ゲート:つまみから通常ゲートと同様に線を引く'})}
|
||
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==='internal-gate')group.append(svgEl('circle',{cx,cy,r:13,class:'internal-gate-bracket'}),svgEl('circle',{cx,cy,r:8,class:'internal-gate-bracket inner'}));
|
||
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 boardTimeout(board,callback,delay){
|
||
const timer=setTimeout(()=>{board?.timers?.delete(timer);if(board?.card?.isConnected)callback()},delay);board?.timers?.add(timer);return timer;
|
||
}
|
||
|
||
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';label.hidden=true;label.dataset.boardId=meta.id;label.style.setProperty('--region',region.accent);
|
||
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><em></em></span><small>ショップ</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)
|
||
const obstacleNodes=new Map();for(const[r,c]of p.obstacles||[]){const x=PAD+c*CELL,y=PAD+r*CELL,group=svgEl('g',{class:'obstacle-mark'});obstacleNodes.set(ckey(r,c),group);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),internalClass=g.internal?' internal':'',
|
||
dot=svgEl('circle',{cx:gp[0],cy:gp[1],r:g.internal?5.5:4.5,class:`gate-dot${internalClass}${sealed?' sealed':''}`,fill:'#7d878e'}),
|
||
knob=svgEl('circle',{cx:gp[0],cy:gp[1],r:g.internal?9:7,class:`gate-knob${internalClass}`,'data-gate-knob':i}),
|
||
marker=svgEl('path',{d:gateMarkerPathAt(gp,g.side,sealed,1,g.internal),class:`gate-marker${internalClass}${sealed?' sealed':''}`}),
|
||
hit=svgEl('rect',{...gateHitBox(gp,g.side,g.internal),rx:3,class:`gate-hit${internalClass}`,'clip-path':`url(#${inputClipId})`,'data-gate':i,tabindex:0,role:'button','aria-label':g.internal?`盤面内ゲート ${i+1}`:`${sealed?'終端':'ゲート '+(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(svg,solverBadge,claimBadge,storeButton,scoreLensBadge);world.append(card);boardHudLayer?.append(label);
|
||
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,obstacleNodes,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()};
|
||
boardReset.addEventListener('click',event=>{event.preventDefault();event.stopPropagation();selectBoard(b);playSound('reset');void resetSelectedBoard(b)});
|
||
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(_){}
|
||
releaseBoardAuroraPathCount(board);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.label?.remove?.();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;
|
||
drawing.renderedHandlePosition=setPoint(drawing.renderedHandlePosition,point[0],point[1]);
|
||
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 cursorModel.item(data.cursorStyle)}
|
||
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,
|
||
effectClass=lineEffectClass(path.lineEffect);
|
||
for(const node of cache.strokes)node.setAttribute('class',`path drag-path${effectClass}`);cache.liveTail.setAttribute('class',`path drag-path drag-live-tail${effectClass}`);for(const node of[cache.startHalo,cache.startKnob,cache.halo,cache.knob])node.classList.toggle('line-effect-aurora',path.lineEffect==='aurora');
|
||
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),color=startColor,turnCount=drawing.renderTurnCount||'0',colorChanged=drawing.lastRenderedColor!==color;
|
||
const previousTip=drawing.lastRenderedTip;if(previousTip&&!colorChanged&&cache.logicalRevision===logicalRevision&&Math.abs(previousTip[0]-tip[0])<.45&&Math.abs(previousTip[1]-tip[1])<.45){perfEnd('renderDragFrame',started);return true}drawing.lastRenderedColor=color;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 setBoardHudVisibility(b,visible){if(!b)return false;const next=Boolean(visible);b.card.classList.toggle('hud-current',next);b.label?.classList.toggle('hud-visible',next);if(b.label)b.label.hidden=!next;if(next)positionBoardLabel(b);return next}
|
||
function activateBoardHud(b){
|
||
if(!b)return false;const previous=rendered.get(hudBoardId);hudBoardId=b.id;
|
||
if(previous&&previous!==b)setBoardHudVisibility(previous,false);
|
||
setBoardHudVisibility(b,boardPlayHudVisible(b));return true;
|
||
}
|
||
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);setBoardHudVisibility(b,hudVisible);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,storeCell=store?storeCellForMeta(meta):null;
|
||
b.storeButton.hidden=!storeCell;for(const[key,node]of b.obstacleNodes||[])node.style.display=storeCell&&key===ckey(...storeCell)?'none':'';
|
||
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(hudVisible)positionBoardLabel(b);
|
||
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${lineEffectClass(path.lineEffect)}${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${lineEffectClass(path.lineEffect)}${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${lineEffectClass(path.lineEffect)}`}),knob=svgEl('circle',{cx:tip[0],cy:tip[1],r:4.8,class:`endpoint-knob${lineEffectClass(path.lineEffect)}`,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${lineEffectClass(path.lineEffect)}`}),startKnob=svgEl('circle',{cx:startTip[0],cy:startTip[1],r:4.8,class:`endpoint-knob${lineEffectClass(path.lineEffect)}`,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 connectorPath=own||other,connector=svgEl('line',{x1:gp[0],y1:gp[1],x2:end[0],y2:end[1],class:`path connector${lineEffectClass(connectorPath?.lineEffect)}`,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=!isInternalGateIndex(p,i)&&!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),auroraGate=!sealed&&(path?.lineEffect==='aurora'||available&&activeLineColorItem()?.aurora===true);
|
||
b.gateDots[i].setAttribute('fill',color);b.gateDots[i].classList.toggle('line-effect-aurora',auroraGate);
|
||
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('line-effect-aurora',auroraGate);
|
||
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,1,gate.internal));
|
||
b.gateMarkers[i]?.setAttribute('stroke',sealed?'#7d878e':color);b.gateMarkers[i]?.classList.toggle('line-effect-aurora',auroraGate);
|
||
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);
|
||
updateBoardAuroraPathCount(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 COMPLETION_NODE_POOL_LIMIT=16,GEM_PARTICLE_POOL_LIMIT=72;
|
||
const GEM_PARTICLE_KEYFRAME_TEMPLATE=Object.freeze([
|
||
Object.freeze({transform:'translate3d(0,10px,0) scale(.25)',opacity:0}),
|
||
Object.freeze({transform:'',opacity:1,offset:.16}),
|
||
Object.freeze({transform:'',opacity:1,offset:.56}),
|
||
Object.freeze({transform:'',opacity:1,offset:.76}),
|
||
Object.freeze({transform:'',opacity:.15})
|
||
]),GEM_PARTICLE_ANIMATION_OPTIONS_TEMPLATE=Object.freeze({duration:0,delay:0,easing:'cubic-bezier(.2,.72,.18,1)',fill:'forwards'});
|
||
const activeCompletionVisuals=new Map(),completionFlashPool=[],completionBurstPool=[],gemParticlePool=[],activeGemBatches=new Set();
|
||
let activeGemParticleCount=0,gemReceivingTimer=0,gemReceivingUntil=0;
|
||
function takeCompletionNode(pool,className){
|
||
const reused=pool.length>0,node=pool.pop()||document.createElement('div');if(node.isConnected)node.remove();node.className=className;node.removeAttribute('style');node.textContent='';perfCount(reused?'completionNodesReused':'completionNodesCreated');return node;
|
||
}
|
||
function releaseCompletionNode(node,pool){
|
||
if(!node)return;node.remove();node.className='';node.removeAttribute('style');node.textContent='';if(pool.length<COMPLETION_NODE_POOL_LIMIT)pool.push(node);perfGauge('completionPooledNodes',completionFlashPool.length+completionBurstPool.length);
|
||
}
|
||
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');releaseCompletionNode(visual.flash,completionFlashPool);releaseCompletionNode(visual.burst,completionBurstPool);
|
||
perfGauge('activeCompletionEffects',activeCompletionVisuals.size);visual.resolve?.({skipped});visual.resolve=null;return true;
|
||
}
|
||
function completionEffect(b,award){
|
||
const started=perfStart();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);perfGauge('activeCompletionEffects',activeCompletionVisuals.size);
|
||
try{
|
||
if(reducedMotionQuery?.matches){finishCompletionVisual(b.id,false);perfEnd('completionEffectSetup',started);return visual}
|
||
b.card.classList.add('completing');
|
||
visual.flash=takeCompletionNode(completionFlashPool,'completion-flash');visual.burst=takeCompletionNode(completionBurstPool,'completion-burst');visual.burst.textContent=`◆ +${formatScore(award)}`;b.card.append(visual.flash,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)}
|
||
perfEnd('completionEffectSetup',started);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 takeGemParticle(){
|
||
const reused=gemParticlePool.length>0,particle=gemParticlePool.pop()||document.createElement('i');particle.remove();particle.className='gem-particle';particle.setAttribute('aria-hidden','true');particle.removeAttribute('style');
|
||
if(!particle._gemKeyframes){particle._gemKeyframes=GEM_PARTICLE_KEYFRAME_TEMPLATE.map(frame=>({...frame}));particle._gemAnimationOptions={...GEM_PARTICLE_ANIMATION_OPTIONS_TEMPLATE}}
|
||
perfCount(reused?'gemParticlesReused':'gemParticlesCreated');return particle;
|
||
}
|
||
function releaseGemParticle(batch,particle){
|
||
if(!batch?.particles?.has(particle))return false;const animation=batch.particles.get(particle);batch.particles.delete(particle);if(animation){animation.onfinish=null;animation.oncancel=null}
|
||
particle.remove();particle.className='';particle.removeAttribute('style');particle._gemDx=particle._gemDy=particle._gemArc=particle._gemDelay=particle._gemDuration=0;if(gemParticlePool.length<GEM_PARTICLE_POOL_LIMIT)gemParticlePool.push(particle);activeGemParticleCount=Math.max(0,activeGemParticleCount-1);perfGauge('activeCosmeticParticles',activeGemParticleCount);perfGauge('gemPooledNodes',gemParticlePool.length);
|
||
if(!batch.particles.size){clearTimeout(batch.fallbackTimer);for(const timer of batch.fallbackTimers)clearTimeout(timer);batch.fallbackTimers.clear();activeGemBatches.delete(batch)}return true;
|
||
}
|
||
function animateGemParticle(particle){
|
||
const dx=particle._gemDx,dy=particle._gemDy,arc=particle._gemArc,keyframes=particle._gemKeyframes,options=particle._gemAnimationOptions;
|
||
keyframes[1].transform=`translate3d(${arc*.18}px,${-18-Math.abs(arc)*.16}px,0) scale(1.28)`;
|
||
keyframes[2].transform=`translate3d(${arc*.28}px,${-25-Math.abs(arc)*.12}px,0) scale(1.05)`;
|
||
keyframes[3].transform=`translate3d(${dx*.48+arc}px,${dy*.3-Math.abs(arc)}px,0) scale(.95)`;
|
||
keyframes[4].transform=`translate3d(${dx}px,${dy}px,0) scale(.26)`;
|
||
options.duration=particle._gemDuration;options.delay=particle._gemDelay;return particle.animate(keyframes,options);
|
||
}
|
||
function finishGemBatch(batch,cancelAnimations=false){
|
||
if(!batch||!activeGemBatches.has(batch))return false;clearTimeout(batch.fallbackTimer);for(const timer of batch.fallbackTimers)clearTimeout(timer);batch.fallbackTimers.clear();
|
||
const entries=[...batch.particles];if(cancelAnimations)for(const[,animation]of entries)try{animation?.cancel?.()}catch(_){}
|
||
for(const[particle]of entries)releaseGemParticle(batch,particle);activeGemBatches.delete(batch);return true;
|
||
}
|
||
function scheduleGemReceivingEnd(delay){
|
||
const stat=scoreCountEl.closest?.('.stat');stat?.classList.add('gem-receiving');gemReceivingUntil=Math.max(gemReceivingUntil,Date.now()+delay);clearTimeout(gemReceivingTimer);
|
||
gemReceivingTimer=setTimeout(()=>{const remaining=gemReceivingUntil-Date.now();if(remaining>8){scheduleGemReceivingEnd(remaining);return}gemReceivingTimer=0;gemReceivingUntil=0;stat?.classList.remove('gem-receiving')},Math.max(0,delay));
|
||
}
|
||
function cleanupGemEffects(){for(const batch of[...activeGemBatches])finishGemBatch(batch,true);clearTimeout(gemReceivingTimer);gemReceivingTimer=0;gemReceivingUntil=0;scoreCountEl.closest?.('.stat')?.classList.remove('gem-receiving')}
|
||
function playGemCollectionAnimation(b,award){
|
||
const started=perfStart(),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);
|
||
if(!sources.length){perfEnd('gemEffectSetup',started);return false}
|
||
const batch={particles:new Map(),fallbackTimers:new Set(),fallbackTimer:0},fragment=document.createDocumentFragment();activeGemBatches.add(batch);let latest=0;
|
||
sources.forEach(([x,y],index)=>{
|
||
const particle=takeGemParticle();particle.style.left=`${x}px`;particle.style.top=`${y}px`;fragment.append(particle);
|
||
particle._gemDx=target[0]-x;particle._gemDy=target[1]-y;particle._gemArc=(index%2?-1:1)*(24+(index%5)*9);particle._gemDelay=reduced?0:index*48;particle._gemDuration=reduced?520:1450+(index%4)*110;latest=Math.max(latest,particle._gemDelay+particle._gemDuration);
|
||
batch.particles.set(particle,null);activeGemParticleCount++;
|
||
});
|
||
document.body.append(fragment);perfGauge('activeCosmeticParticles',activeGemParticleCount);perfGauge('gemPooledNodes',gemParticlePool.length);
|
||
for(const particle of batch.particles.keys()){
|
||
try{
|
||
const flight=animateGemParticle(particle);batch.particles.set(particle,flight);flight.onfinish=()=>releaseGemParticle(batch,particle);flight.oncancel=()=>releaseGemParticle(batch,particle);
|
||
}catch(_){const delay=particle._gemDelay,duration=particle._gemDuration,timer=setTimeout(()=>{batch.fallbackTimers.delete(timer);releaseGemParticle(batch,particle)},delay+duration);batch.fallbackTimers.add(timer)}
|
||
}
|
||
batch.fallbackTimer=setTimeout(()=>finishGemBatch(batch,true),latest+250);scheduleGemReceivingEnd(latest+120);perfEnd('gemEffectSetup',started);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(){}
|
||
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(interactionActive('persistence')){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(interactionActive('persistence')){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);
|
||
writeDirtyRecoveryJournal();
|
||
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){
|
||
let published=false;
|
||
for(let attempt=0;attempt<3&&!published;attempt++){published=await pushCloudPending(b.id);if(!published&&metaState(b.id)?.solved===true)await sleep(180*(attempt+1))}
|
||
if(!published){
|
||
finishCompletionVisual(b.id,true);
|
||
if(data.metas[b.id]===b.meta&&data.states[b.id]===st){data.states[b.id]=previous.state;normalizedStateObjects.add(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);markGlobalDirty()}
|
||
await persistNow({skipCloud:true});restoreCloudPushPending();armCloudPush(500);renderBoard(rendered.get(b.id));updateHud();return false;
|
||
}
|
||
removeClaim(b.id,'cleared');
|
||
}
|
||
const immediateBoard=rendered.get(b.id);if(immediateBoard?.card?.isConnected){renderBoard(immediateBoard);playSound('clear');completionEffect(immediateBoard,award);playGemCollectionAnimation(immediateBoard,award)}updateHud();
|
||
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 pointerId=b?.drawing?.pointerId,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){commitConnectedLineVisuals(b,pi);cancelBoardDragFrame(b);clearDragRender(b);hidePickupHandleOverlay();if(pointerId!=null)safeRelease(b.svg,pointerId);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;
|
||
const cosmetic={lineEffect:a.lineEffect||o.lineEffect||null,ownerId:a.ownerId||o.ownerId||null};
|
||
if(!a.detachedStart&&!o.detachedStart)merged={startGate:a.startGate,endGate:o.startGate,openGate:null,cells,colorIndex:aColor,startColorIndex:aColor,endColorIndex:oStartColor,...cosmetic};
|
||
else if(!a.detachedStart&&o.detachedStart)merged={startGate:a.startGate,endGate:null,openGate:null,detachedStart:false,cells,colorIndex:aColor,startColorIndex:aColor,endColorIndex:oRemainingColor,...cosmetic};
|
||
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,...cosmetic}}
|
||
else merged={startGate:a.startGate,endGate:null,openGate:null,detachedStart:true,cells,colorIndex:aColor,startColorIndex:aColor,endColorIndex:oRemainingColor,...cosmetic};
|
||
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){commitConnectedLineVisuals(b,metaState(b.id).paths.length-1);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;
|
||
const touchedKey=specialCellSet(b.p).locks.some(candidate=>sameCell(candidate.key,cell));
|
||
applyBoardCommand(b,()=>{path.cells.push(cell);path.openGate=null},{paint:!deferRender,persist:!deferRender,invalidate:!deferRender});
|
||
if(touchedKey)playSound('key');
|
||
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;playSound('warp')
|
||
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 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(typeof renderBoardNow==='function')renderBoardNow(b);if(pointerId!=null)safeRelease(b.svg,pointerId);playSound('remove');queueMicrotask(reconcileDrawingPresentation)}
|
||
return Boolean(done);
|
||
}
|
||
function extendPointerTo(b,rawPoint,deferFrameRender=false,catchupLimit=DRAG_MAX_LIVE_CATCHUP_CELLS){
|
||
const prepareStarted=perfStart();
|
||
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;
|
||
perfEnd('pickupModelPrepare',prepareStarted);const topologyStarted=perfStart();
|
||
const emitBatchedFeedback=()=>{if(!feedbackCell)return;const now=perfNow();if(now-(b.lastDragToneAt||0)>=90){b.lastDragToneAt=now;playConfirmTone(feedbackLength)}if(uiSettings.lightweightRendering||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}
|
||
perfEnd('pickupModelTopology',topologyStarted);const traversalStarted=perfStart();
|
||
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}
|
||
const cellStarted=perfStart(),extended=extendOne(b,cell,true,segmentOccupancy,true);perfEnd('pickupModelCell',cellStarted);
|
||
if(!extended){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)}
|
||
}
|
||
perfEnd('pickupModelTraversal',traversalStarted);
|
||
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,lineEffect:activeLineColorItem()?.aurora?'aurora':null,ownerId:currentPlayerId()});
|
||
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{gestureCoordinator.release(id,'draw');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&&data.selectedBoardId===b.id;
|
||
setActiveBoard(b.id);data.selectedBoardId=b.id;markGlobalDirty(false);
|
||
if(!alreadySelected&&paint)renderBoard(b);updateSelectedProgress(b)
|
||
}
|
||
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||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||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(){if(!pickupHandleOverlay)return;pickupHandleOverlay.classList.remove('visible');pickupHandleOverlay.style.removeProperty('transform')}
|
||
function syncPickupHandleDesign(){
|
||
if(!pickupHandleOverlay)return false;
|
||
const item=activeCustomCursorItem(),style=item?.cursorStyle||'default';if(pickupHandleOverlay.dataset.cursorStyle===style)return Boolean(item);
|
||
pickupHandleOverlay.dataset.cursorStyle=style;pickupHandleOverlay.classList.toggle('custom-cursor',Boolean(item));pickupHandleOverlay.classList.toggle('flag-cursor',Boolean(item?.flagAsset));
|
||
if(item?.flagAsset){const image=document.createElement('img');image.src=item.flagAsset;image.alt='';image.draggable=false;pickupHandleOverlay.replaceChildren(image)}else pickupHandleOverlay.textContent=item?.cursorEmoji||'';
|
||
return Boolean(item);
|
||
}
|
||
function activeDrawingLineColorIndex(path){return Number.isInteger(path?.startColorIndex)?path.startColorIndex:Number.isInteger(path?.colorIndex)?path.colorIndex:0}
|
||
function updatePickupHandleOverlay(b,drawing,clientX,clientY){
|
||
if(!pickupHandleOverlay)return false;if(!drawing||!Number.isFinite(clientX)||!Number.isFinite(clientY)){hidePickupHandleOverlay();return false}
|
||
const path=activePath(b),color=LINE_COLORS[activeDrawingLineColorIndex(path)%LINE_COLORS.length],transform=`translate3d(${clientX}px,${clientY}px,0) translate(-50%,-50%)`;
|
||
syncPickupHandleDesign();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 uiSettings.lightweightRendering&&(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 ensureBoardDragScheduler(b){
|
||
if(!b)return null;
|
||
if(!b.dragScheduler)b.dragScheduler=DragSchedulerApi.createDragScheduler({
|
||
maxSamples:DRAG_MAX_POINTER_SAMPLES,
|
||
trim:trimBoardPointerSamples,
|
||
interval:DRAG_FRAME_INTERVAL,
|
||
tolerance:INTERACTION_FRAME_TOLERANCE_MS,
|
||
watchdogDelay:DRAG_DISPLAY_WATCHDOG_MS,
|
||
now:perfNow,
|
||
onWatchdog:()=>perfCount('pickupDisplayWatchdogFrames'),
|
||
onFrame:(_scheduler,timestamp)=>{
|
||
if(b.releaseDrain)processBoardPointerReleaseDrain(b,b.releaseDrain,timestamp);
|
||
else processBoardDragFrame(b,timestamp);
|
||
}
|
||
});
|
||
return b.dragScheduler;
|
||
}
|
||
function cancelBoardDragFrame(b){
|
||
if(!b)return;
|
||
b.dragScheduler?.cancel();b.releaseDrain=null;
|
||
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;hidePickupHandleOverlay();
|
||
}
|
||
function scheduleBoardDragFrame(b){
|
||
return Boolean(ensureBoardDragScheduler(b)?.requestFrame());
|
||
}
|
||
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 scheduler=ensureBoardDragScheduler(b);
|
||
if(!scheduler.push(sample))return null;
|
||
b.dragVisualActiveUntil=perfNow()+40;return sample;
|
||
}
|
||
function appendBoardPointerSamples(b,event){return setBoardPointerSample(b,pointerEventSamples(event))}
|
||
function processBoardDragFrame(b,timestamp){
|
||
const started=perfStart(),scheduler=ensureBoardDragScheduler(b),dragState=scheduler.inspect();perfCount('dragFrames');markVisualFrame(timestamp);const move=scheduler.latest(),drawing=b.drawing;if(!move||!drawing||drawing.pointerId!==move.pointerId){cancelBoardDragFrame(b);perfEnd('processBoardDragFrame',started);return}
|
||
const logicalSlotDue=!b.dragNextFrameAt||timestamp+2>=b.dragNextFrameAt,
|
||
freshVisualInput=dragState.freshVisual,
|
||
freshLogicalInput=dragState.freshLogical;
|
||
if(freshVisualInput){recordInteractionCommit('pickupVisual',timestamp,move.inputAt,true);b.dragVisualCommitAt=timestamp;scheduler.markVisual()}
|
||
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}
|
||
b.dragVisualX=move.clientX;b.dragVisualY=move.clientY;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||scheduler.hasLogical());
|
||
if(logicalDue){
|
||
const modelStarted=perfStart();let processed=0,logicalMove=move;
|
||
do{
|
||
logicalMove=freshLogicalInput&&scheduler.hasLogical()?scheduler.takeLogical(1)[0]: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&&scheduler.hasLogical()&&processed<DRAG_MAX_LOGICAL_SAMPLES_PER_FRAME&&perfNow()-modelStarted<DRAG_MODEL_BUDGET_MS)
|
||
recordInteractionCommit('pickupLogical',timestamp,logicalMove.inputAt,freshLogicalInput);
|
||
if(freshLogicalInput&&!scheduler.hasLogical())scheduler.markLogical();
|
||
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);
|
||
if(usesLightweightDragOverlay(b)){recordPickupStartFrame(b,b.drawing);dirtyBoards.delete(b)}
|
||
else if(b.drawing?.pointerId===move.pointerId)renderDragFrame(b);
|
||
perfEnd('pickupVisualWork',visualStarted);
|
||
if((perfNow()<b.dragVisualActiveUntil||velocity[0]||velocity[1]||b.drawing?.catchupPending||scheduler.hasLogical())&&b.drawing?.pointerId===move.pointerId)scheduleBoardDragFrame(b);
|
||
const workDuration=perfEnd('processBoardDragFrame',started);observeInteractionFrame(timestamp,workDuration);
|
||
}
|
||
function queueBoardPointerMove(b,event){
|
||
appendBoardPointerSamples(b,event);
|
||
scheduleBoardDragFrame(b);
|
||
}
|
||
function scheduleBoardPointerReleaseDrain(b){
|
||
const drain=b?.releaseDrain;if(!drain)return false;
|
||
ensureBoardDragScheduler(b).requestFrame();return true;
|
||
}
|
||
function processBoardPointerReleaseDrain(b,drain,timestamp){
|
||
if(b.releaseDrain!==drain)return;
|
||
const started=perfStart();let processed=0;
|
||
while(drain.index<drain.moves.length&&processed<DRAG_MAX_LOGICAL_SAMPLES_PER_FRAME&&perfNow()-started<DRAG_MODEL_BUDGET_MS){
|
||
const move=drain.moves[drain.index],point=pointerInsideBoardScreen(b,move)?eventToSvg(b,move):null;
|
||
if(!drain.tracedIndexes.has(drain.index)){drain.tracedIndexes.add(drain.index);const 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,DRAG_MAX_RELEASE_CATCHUP_CELLS);
|
||
processed++;
|
||
if(b.releaseDrain!==drain){queueMicrotask(drain.onComplete);perfEnd('pickupReleaseDrainWork',started);return}
|
||
if(!b.drawing||b.drawing.pointerId!==drain.pointerId||!b.drawing.catchupPending)drain.index++;
|
||
}
|
||
if(b.drawing&&!usesLightweightDragOverlay(b))renderDragFrame(b);
|
||
recordInteractionCommit('pickupReleaseDrain',timestamp,drain.inputAt,true);perfCount('pickupReleaseDrainFrames');perfEnd('pickupReleaseDrainWork',started);
|
||
if(drain.index<drain.moves.length&&b.drawing?.pointerId===drain.pointerId){scheduleBoardPointerReleaseDrain(b);return}
|
||
b.releaseDrain=null;ensureBoardDragScheduler(b).beginSettling();queueMicrotask(drain.onComplete);
|
||
}
|
||
function flushBoardPointerMove(b,event,onComplete=()=>{}){
|
||
const scheduler=ensureBoardDragScheduler(b),sample=event?pointerEventSamples(event):null;
|
||
scheduler.beginDrain(sample);const moves=scheduler.drainLogical();
|
||
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;hidePickupHandleOverlay();
|
||
b.lastFlushPointerCells=[];
|
||
if(!moves.length||!b.drawing){queueMicrotask(onComplete);return false}
|
||
b.releaseDrain={pointerId:b.drawing.pointerId,moves,index:0,inputAt:moves[moves.length-1]?.inputAt||perfNow(),lastFrameAt:0,tracedIndexes:new Set(),onComplete};
|
||
scheduleBoardPointerReleaseDrain(b);return true;
|
||
}
|
||
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;
|
||
const step=timestamp=>{
|
||
const pending=b.pendingClaimPointer;if(!pending){b.pendingClaimFrame=0;return}
|
||
if(pending.lastFrameAt&×tamp+INTERACTION_FRAME_TOLERANCE_MS<pending.lastFrameAt+DRAG_FRAME_INTERVAL){b.pendingClaimFrame=requestAnimationFrame(step);return}
|
||
b.pendingClaimFrame=0;const point=eventToSvg(b,pending);if(!point)return;
|
||
pending.lastFrameAt=timestamp;pending.preview.setAttribute('transform',`translate(${point[0]} ${point[1]})`);recordInteractionCommit('pickupPreview',timestamp,pending.inputAt);markVisualFrame(timestamp);perfCount('pickupPreviewFrames');
|
||
};
|
||
b.pendingClaimFrame=requestAnimationFrame(step);
|
||
}
|
||
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||pending.released)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 markPendingClaimPointerReleased(b,event){
|
||
const pending=b?.pendingClaimPointer;if(!pending||pending.pointerId!==event.pointerId)return false;
|
||
const sample=pointerEventSamples(event)||{pointerId:event.pointerId,clientX:pending.clientX,clientY:pending.clientY,inputAt:Number(event.timeStamp)||perfNow()};
|
||
pending.clientX=sample.clientX;pending.clientY=sample.clientY;pending.inputAt=sample.inputAt;pending.released=true;pending.releaseSample=sample;(pending.samples||(pending.samples=[])).push(sample);trimBoardPointerSamples(pending.samples);
|
||
if(b.pendingClaimFrame)cancelAnimationFrame(b.pendingClaimFrame);b.pendingClaimFrame=0;event.preventDefault?.();return true;
|
||
}
|
||
function clearPendingClaimPointer(b,pointerId,{release=true,releaseGesture=release}={}){
|
||
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(_){}
|
||
if(releaseGesture)gestureCoordinator.release(pending.pointerId,'draw');
|
||
refreshInteractionState();return pending;
|
||
}
|
||
function recoverLostBoardPointerCapture(b,event){
|
||
const pointerId=event?.pointerId,drawing=b?.drawing;if(pointerId==null||drawing?.pointerId!==pointerId||!realtimeHeldPointers.has(pointerId))return false;
|
||
requestAnimationFrame(()=>{if(b.card?.isConnected&&b.drawing?.pointerId===pointerId&&realtimeHeldPointers.has(pointerId))safeCapture(b.svg,pointerId)});return true;
|
||
}
|
||
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;
|
||
activateBoardHud(b);setPickupScenePresentation(b,true);refreshInteractionState();
|
||
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);
|
||
const latest=samples[samples.length-1];if(latest){b.dragVisualX=latest.clientX;b.dragVisualY=latest.clientY;updatePickupHandleOverlay(b,drawing,latest.clientX,latest.clientY)}
|
||
scheduleBoardDragFrame(b);perfEnd('pickupPointerDownCommit',started);return true;
|
||
}
|
||
function bindBoard(b){
|
||
b.card.addEventListener('focus',()=>selectBoard(b));
|
||
b.svg.addEventListener('pointerdown',async e=>{
|
||
const endpointTarget=e.target.closest?.('.endpoint-hit'),gateTarget=e.target.closest?.('.gate-hit'),boardTarget=e.target.closest?.('.board-input-surface');
|
||
if(e.button!==0||b.joiningPaths||!endpointTarget&&!gateTarget&&!boardTarget||!gestureCoordinator.claim(e.pointerId,'draw'))return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
selectBoard(b,{paint:false});
|
||
let st=metaState(b.id);
|
||
if(st.solved){gestureCoordinator.release(e.pointerId,'draw');return}
|
||
const directGate=Number(gateTarget?.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||!pending.released&&!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),finishReleased=activated=>{if(!pending.released)return activated;if(activated)queueMicrotask(()=>finishPointer(pending.releaseSample||pending,true));else safeRelease(b.svg,e.pointerId);return activated};
|
||
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);const activated=activateBoardPointerDrag(b,pending,point);if(!activated){b.armedGate=null;renderBoard(b)}finishReleased(activated);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)}
|
||
finishReleased(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);
|
||
finishReleased(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);
|
||
const activated=activateBoardPointerDrag(b,pending,point);if(!activated){b.armedGate=null;renderBoard(b)}finishReleased(activated);
|
||
return;
|
||
}
|
||
if(Number.isInteger(occupiedPath)){
|
||
if(reopenPathAtCell(b,occupiedPath,hintCell,e.pointerId)){playSound('grab');finishReleased(activateBoardPointerDrag(b,pending,point));return}
|
||
}
|
||
if(Number.isInteger(targetGate)){
|
||
armGate(b,targetGate);startGate(b,targetGate,e.pointerId);
|
||
const activated=activateBoardPointerDrag(b,pending,point);if(!activated){b.armedGate=null;renderBoard(b)}finishReleased(activated);
|
||
return;
|
||
}
|
||
const cell=cellAt(b,point);
|
||
if(!cell){finishReleased(false);return;}
|
||
const pi=occupiedMap(st).get(ckey(...cell));
|
||
if(pi!=null){
|
||
if(reopenPathAtCell(b,pi,cell,e.pointerId)){
|
||
finishReleased(activateBoardPointerDrag(b,pending,point));return;
|
||
}
|
||
}
|
||
finishReleased(false);
|
||
|
||
});
|
||
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){if(flush===true)markPendingClaimPointerReleased(b,e);else clearPendingClaimPointer(b,e.pointerId);return}
|
||
if(b.releaseDrain?.pointerId===e.pointerId)return;
|
||
if(!b.drawing){cancelBoardDragFrame(b);clearDragRender(b);hidePickupHandleOverlay();safeRelease(b.svg,e.pointerId);return}
|
||
if(b.drawing.pointerId!==e.pointerId)return;
|
||
const release=(paintCurrent=false)=>{if(paintCurrent&&b.card?.isConnected)renderBoardNow(b);cancelBoardDragFrame(b);clearDragRender(b);hidePickupHandleOverlay();safeRelease(b.svg,e.pointerId);queueMicrotask(()=>updateCustomCursorFromPointer(e))};
|
||
if(flush===true){flushBoardPointerMove(b,e,()=>finishPointer(e,'settled'));return}
|
||
if(flush!=='settled')cancelBoardDragFrame(b);
|
||
if(!b.drawing||b.drawing.pointerId!==e.pointerId){release();return}
|
||
if(flush==='settled'&&reconnectOpenGateOnRelease(b)){release(true);return}
|
||
if(discardUnmovedCreatedPath(b)){release(true);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('両端がワープセルにある未接続の線を削除しました。');
|
||
renderBoardNow(b);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){if(!b.pendingClaimPointer.released)clearPendingClaimPointer(b,e.pointerId,{release:false,releaseGesture:true})}else if(b.releaseDrain?.pointerId===e.pointerId)return;else if(b.drawing?.pointerId===e.pointerId){if(!recoverLostBoardPointerCapture(b,e))finishPointer(e,false)}else{gestureCoordinator.release(e.pointerId,'draw','lost-capture');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);activateBoardHud(b);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;activateBoardHud(b);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,{allowDuringInteraction=false}={}){
|
||
if(markDirty)overviewDirty=true;
|
||
if(allowDuringInteraction)overviewAllowInteractionBuild=true;
|
||
if(interactionActive('overview')&&!overviewAllowInteractionBuild)return;
|
||
if(!overviewCanvas||!inWorldOverview()||!overviewDirty||overviewFrame||overviewDelayTimer)return;
|
||
const step=timestamp=>{
|
||
if(!overviewFrame)return;
|
||
if(overviewAllowInteractionBuild&×tamp-overviewInteractionLastBuild<OVERVIEW_INTERACTION_REBUILD_INTERVAL){overviewFrame=requestAnimationFrame(step);return}
|
||
if(timestamp-overviewLastDraw<AUXILIARY_FRAME_INTERVAL-1){overviewFrame=requestAnimationFrame(step);return}
|
||
overviewFrame=0;overviewLastDraw=timestamp;markVisualFrame(timestamp);
|
||
const rebuild=()=>{
|
||
overviewDelayTimer=0;const allow=overviewAllowInteractionBuild;overviewAllowInteractionBuild=false;
|
||
if(interactionActive('overview')&&!allow)return;
|
||
drawWorldOverview({allowDuringInteraction:allow});if(allow)overviewInteractionLastBuild=perfNow();
|
||
};
|
||
overviewDelayTimer=typeof requestIdleCallback==='function'?requestIdleCallback(rebuild,{timeout:180}):setTimeout(rebuild,0);
|
||
};
|
||
overviewFrame=requestAnimationFrame(step);
|
||
}
|
||
function overviewCacheNeedsInteractionRebuild(){
|
||
if(!overviewCache)return true;
|
||
const rect=getViewportRect(),width=Math.max(1,Math.round(rect.width)),height=Math.max(1,Math.round(rect.height)),[centerX,centerY]=cameraCenterInChunks(),scaleRatio=cam.scale/Math.max(MIN_CAMERA_SCALE,overviewCache.scale);
|
||
return overviewCache.revision!==minimapWorldRevision||overviewCache.width!==width||overviewCache.height!==height||
|
||
Math.abs(centerX-overviewCache.anchorX)*overviewCache.unit*Math.max(.01,scaleRatio)>overviewCache.overscan*.82||
|
||
Math.abs(centerY-overviewCache.anchorY)*overviewCache.unit*Math.max(.01,scaleRatio)>overviewCache.overscan*.82;
|
||
}
|
||
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(),ratio=Math.max(.01,cam.scale/Math.max(MIN_CAMERA_SCALE,overviewCache.scale)),
|
||
left=width/2-(overviewCache.baseWidth/2+(centerX-overviewCache.anchorX)*overviewCache.unit)*ratio,
|
||
top=height/2-(overviewCache.baseHeight/2+(centerY-overviewCache.anchorY)*overviewCache.unit)*ratio;
|
||
overviewCanvas.hidden=false;overviewCanvas.style.transform=`translate3d(${left.toFixed(2)}px,${top.toFixed(2)}px,0) scale(${ratio})`;
|
||
perfCount('overviewTransformUpdates');return true;
|
||
}
|
||
function rebuildWorldOverviewCache(width,height,dpr,centerX,centerY){
|
||
if(interactionActive('overview'))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)),storeCount=drawMapStores(context,visibleMetas,mapX,mapY,unit);
|
||
overviewCache={revision:minimapWorldRevision,width,height,dpr,scale:cam.scale,anchorX:centerX,anchorY:centerY,unit,overscan,baseWidth,baseHeight,longSegments,storeCount,boardCount:ids.size};
|
||
perfCount('overviewCacheBuilds');perfGauge('overviewBoards',ids.size);perfGauge('overviewPaths',longSegments);perfGauge('overviewShops',storeCount);perfEnd('rebuildWorldOverviewCache',started);
|
||
}
|
||
function drawWorldOverview({allowDuringInteraction=false}={}){
|
||
const started=perfStart();if(!overviewCanvas||!inWorldOverview())return;
|
||
const interacting=interactionActive('overview');if(interacting&&!allowDuringInteraction)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(),scaleRatio=overviewCache?cam.scale/Math.max(MIN_CAMERA_SCALE,overviewCache.scale):1;
|
||
const stale=!overviewCache||overviewCache.revision!==minimapWorldRevision||overviewCache.width!==width||overviewCache.height!==height||overviewCache.dpr!==dpr||(!interacting&&Math.abs(overviewCache.scale-cam.scale)>1e-6)||
|
||
Math.abs(centerX-overviewCache.anchorX)*overviewCache.unit*Math.max(.01,scaleRatio)>overviewCache.overscan*.82||Math.abs(centerY-overviewCache.anchorY)*overviewCache.unit*Math.max(.01,scaleRatio)>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;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=interactionActive('world');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(interactionActive('world')){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}
|
||
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){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}
|
||
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,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()){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||interactionActive('camera'));if(!cameraGestureActive)rebaseWorldOrigin();const overview=updateZoomPresentation();
|
||
const paint=timestamp=>{if(!immediate&&cameraLastDraw&×tamp-cameraLastDraw<GLOBAL_FRAME_INTERVAL-1){cameraFrame=requestAnimationFrame(paint);return}cameraFrame=0;cameraFrameDelayTimer=0;cameraLastDraw=timestamp;markVisualFrame(timestamp);if(overview){positionCachedWorldOverview();if(overviewCacheNeedsInteractionRebuild()){overviewDirty=true;scheduleWorldOverview(false,{allowDuringInteraction:cameraGestureActive})}}else world.style.transform=`translate3d(${cam.x}px,${cam.y}px,0) scale(${cam.scale})`;repositionActiveBoardHud()};
|
||
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,{relative=false,inputAt=perfNow()}={}){
|
||
const base=pendingCameraInteraction||cam,r=getViewportRect(),mx=clientX-r.left,my=clientY-r.top,wx=(mx-base.x)/base.scale,wy=(my-base.y)/base.scale,
|
||
scale=Math.max(MIN_CAMERA_SCALE,Math.min(1.8,relative?base.scale*nextScale:nextScale));
|
||
queueCameraInteraction({scale,x:mx-wx*scale,y:my-wy*scale,inputAt});
|
||
}
|
||
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 randomUnsolvedMeta(){
|
||
let candidates=Object.values(data.metas).filter(meta=>meta&&!metaState(meta.id).solved);
|
||
if(!candidates.length)candidates=Object.values(data.metas).filter(Boolean);
|
||
if(!candidates.length)return null;
|
||
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&&!meta.puzzle)await hydrateMeta(meta);
|
||
return meta;
|
||
}
|
||
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)}
|
||
refreshInteractionState();
|
||
}
|
||
function cancelCameraGestures(reason='cancelled'){
|
||
if(pendingCameraInteraction)flushCameraInteraction();
|
||
const pointerIds=new Set(touchPoints.keys());if(pan?.id!=null)pointerIds.add(pan.id);
|
||
for(const pointerId of pointerIds){gestureCoordinator.release(pointerId,null,reason);try{if(viewport.hasPointerCapture?.(pointerId))viewport.releasePointerCapture(pointerId)}catch(_){}}
|
||
touchPoints.clear();pan=null;pinch=null;viewport.classList.remove('panning');
|
||
}
|
||
function cancelMinimapGesture(reason='cancelled'){
|
||
const pointerId=minimapPointerState?.id;if(pointerId==null)return;
|
||
minimapPointerState=null;gestureCoordinator.release(pointerId,'minimap',reason);interactionState.clear('minimap');
|
||
try{if(minimapCanvas.hasPointerCapture?.(pointerId))minimapCanvas.releasePointerCapture(pointerId)}catch(_){}
|
||
}
|
||
function cancelAllPointerInteractions(reason='cancelled'){
|
||
cancelReactionGesture();cancelPointerGestures();cancelCameraGestures(reason);cancelMinimapGesture(reason);gestureCoordinator.cancelAll(reason);
|
||
interactionState.clear('reaction');interactionState.clear('minimap');interactionState.clear('wheel');refreshInteractionState(false);scheduleInteractionSettlePresentation();
|
||
}
|
||
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(next,timestamp=perfNow()){
|
||
const started=perfStart();if(pendingCameraInteraction===next)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;
|
||
}
|
||
const cameraInteractionScheduler=FrameSchedulerApi.createFrameScheduler({
|
||
interval:DRAG_FRAME_INTERVAL,
|
||
tolerance:INTERACTION_FRAME_TOLERANCE_MS,
|
||
watchdogDelay:CAMERA_DISPLAY_WATCHDOG_MS,
|
||
now:perfNow,
|
||
onWatchdog:()=>perfCount('cameraDisplayWatchdogFrames'),
|
||
commit:commitCameraInteraction
|
||
});
|
||
function queueCameraInteraction(next){
|
||
pendingCameraInteraction=next;cameraInteractionScheduler.push(next);
|
||
}
|
||
function flushCameraInteraction(){
|
||
const committed=cameraInteractionScheduler.flush();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.target===minimapCanvas||e.target?.closest?.('#minimapCanvas')||gestureCoordinator.owner(e.pointerId)&&!gestureCoordinator.owns(e.pointerId,'pan'))return;
|
||
if(e.button===0&&inWorldOverview()){
|
||
const rect=getViewportRect(),worldX=renderOriginX+(e.clientX-rect.left-cam.x)/(UNIT*cam.scale),worldY=renderOriginY+(e.clientY-rect.top-cam.y)/(UNIT*cam.scale),storeMeta=nearestStoreMetaAtWorldPoint(worldX,worldY,18,UNIT*cam.scale);
|
||
if(storeMeta){e.preventDefault();e.stopPropagation();playSound('shop');openStoreMeta(storeMeta);return}
|
||
}
|
||
if(e.pointerType==='touch'){
|
||
touchPoints.set(e.pointerId,{x:e.clientX,y:e.clientY});
|
||
if(touchPoints.size===2){
|
||
e.preventDefault();e.stopPropagation();cancelPointerGestures();
|
||
for(const pointerId of touchPoints.keys())gestureCoordinator.claim(pointerId,'pinch',{replace:true});
|
||
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,button,a,input,select,textarea,[role="button"]')){
|
||
if(!gestureCoordinator.claim(e.pointerId,'pan')){touchPoints.delete(e.pointerId);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:true};viewport.classList.add('panning');
|
||
try{viewport.setPointerCapture(e.pointerId)}catch(_){}refreshInteractionState();
|
||
}
|
||
return;
|
||
}
|
||
if(e.button!==2&&!leftFieldPanAllowed(e))return;
|
||
if(!gestureCoordinator.claim(e.pointerId,'pan'))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;gestureCoordinator.release(e.pointerId,'pinch');for(const pointerId of touchPoints.keys())gestureCoordinator.release(pointerId,'pinch')}
|
||
if(pan&&pan.id===e.pointerId){flushCameraInteraction();pan=null;ended=true;gestureCoordinator.release(e.pointerId,'pan');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);
|
||
window.addEventListener('pointermove',movePan,true);
|
||
window.addEventListener('pointerup',endReactionGesture,true);
|
||
window.addEventListener('pointerup',stopPan,true);
|
||
window.addEventListener('pointercancel',cancelReactionGesture,true);
|
||
window.addEventListener('pointercancel',stopPan,true);
|
||
window.addEventListener('blur',()=>cancelAllPointerInteractions('blur'));
|
||
viewport.addEventListener('lostpointercapture',stopPan,true);
|
||
viewport.addEventListener('contextmenu',e=>e.preventDefault());
|
||
viewport.addEventListener('wheel',e=>{e.preventDefault();pulseWheelInteraction();zoomAt(e.clientX,e.clientY,Math.exp(-e.deltaY*.0012),{relative:true,inputAt:Number(e.timeStamp)||perfNow()})},{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'),
|
||
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'),
|
||
timeAttackResultTotal=document.querySelector('#timeAttackResultTotal'),timeAttackShareText=document.querySelector('#timeAttackShareText'),
|
||
copyTimeAttackBtn=document.querySelector('#copyTimeAttack'),closeTimeAttackBtn=document.querySelector('#closeTimeAttack'),
|
||
timeAttackCountdownOverlay=document.querySelector('#timeAttackCountdownOverlay'),timeAttackCountdownValue=timeAttackCountdownOverlay.querySelector('span'),
|
||
settingsModal=document.querySelector('#settingsModal'),settingsPanel=settingsModal.querySelector('.settings-panel'),settingsPlayerName=document.querySelector('#settingsPlayerName'),lightweightRenderingToggle=document.querySelector('#lightweightRenderingToggle'),soundEnabledToggle=document.querySelector('#soundEnabledToggle'),resetSettingsBtn=document.querySelector('#resetSettings'),closeSettingsBtn=document.querySelector('#closeSettings'),customEmojiCursor=document.querySelector('#customEmojiCursor'),pickupHandleOverlay=document.querySelector('#pickupHandleOverlay');
|
||
let openStoreBoardId=null;
|
||
let timeAttackTimer=null,timeAttackCountdownActive=false,timeAttackCountdownHideTimer=0,timeAttackFinalCountdownSecond=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){
|
||
cancelAllPointerInteractions('modal');gestureCoordinator.claim(`dialog:${root.id||'root'}`,'dialog',{replace:true});interactionState.set('dialog',root.id||'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');
|
||
gestureCoordinator.release(`dialog:${root.id||'root'}`,'dialog');interactionState.clear('dialog');
|
||
}
|
||
function debugAllItemsEnabled(){return DEBUG_PURCHASE_MODE}
|
||
function storeItemActive(item){return Boolean(item&&(item.cursorStyle?data.cursorStyle===item.cursorStyle:item.lineColor?data.lineColorStyle===item.id:item.lineEffect?data.lineEffectStyle===item.lineEffect:item.reactionStyle?data.reactionStyle===item.reactionStyle:item.scoreLens?data.scoreLensEnabled:false))}
|
||
function setItemIcon(node,item){
|
||
node.classList.toggle('emoji-glyph',Boolean(item?.cursorEmoji&&!item?.flagAsset));
|
||
node.classList.toggle('line-color-swatch',Boolean(item?.lineColor));node.classList.toggle('aurora-swatch',item?.aurora===true);
|
||
if(item?.lineColor)node.style.setProperty('--item-color',item.lineColor);else node.style.removeProperty('--item-color');
|
||
if(item?.flagAsset){
|
||
let image=node.firstElementChild?.matches?.('img.item-flag-image')?node.firstElementChild:null;
|
||
if(!image){image=document.createElement('img');image.className='item-flag-image';image.alt='';image.draggable=false;image.loading='lazy';image.decoding='async';image.fetchPriority='low';image.addEventListener('load',()=>perfCount('inventoryImagesDecoded'));node.replaceChildren(image);perfCount('inventoryImagesCreated')}
|
||
if(image.getAttribute('src')!==item.flagAsset)image.src=item.flagAsset;
|
||
}else if(node.textContent!==(item?.icon||''))node.textContent=item?.icon||'';
|
||
}
|
||
function panelScrollPosition(panel){return{top:Number(panel?.scrollTop)||0,left:Number(panel?.scrollLeft)||0}}
|
||
function restorePanelScroll(panel,position){if(!panel||!position)return;panel.scrollTop=position.top;panel.scrollLeft=position.left}
|
||
const inventoryCollapsedCategories=new Set(),inventoryCategoryViews=new Map(),inventoryItemViews=new Map(),
|
||
inventoryLineEffectItemIds=new Map(STORE_ITEMS.filter(item=>item.lineEffect).map(item=>[item.lineEffect,item.id])),
|
||
inventoryReactionStyleItemIds=new Map(STORE_ITEMS.filter(item=>item.reactionStyle).map(item=>[item.reactionStyle,item.id]));
|
||
let inventoryHasRendered=false,inventorySelectedCursorItemId=null;
|
||
function rememberInventoryCategoryState(){
|
||
for(const section of inventoryList?.querySelectorAll?.('details.inventory-section[data-category]')||[]){
|
||
if(section.open)inventoryCollapsedCategories.delete(section.dataset.category);else inventoryCollapsedCategories.add(section.dataset.category);
|
||
}
|
||
}
|
||
function createInventoryCategoryView(category){
|
||
const section=document.createElement('details'),title=document.createElement('summary'),titleText=document.createTextNode(category.title),countBadge=document.createElement('span'),list=document.createElement('div');
|
||
section.className=`inventory-section inventory-${category.cursor?'cursors':'items'}-section`;section.dataset.category=category.key;section.open=!inventoryCollapsedCategories.has(category.key);
|
||
title.className='inventory-section-title';title.append(titleText,countBadge);list.className=category.cursor?'inventory-cursor-grid':'inventory-item-list';section.append(title,list);
|
||
section.addEventListener('toggle',()=>{if(section.open)inventoryCollapsedCategories.delete(category.key);else inventoryCollapsedCategories.add(category.key)});
|
||
const view={section,title,titleText,countBadge,list,cursor:category.cursor};inventoryCategoryViews.set(category.key,view);perfCount('inventoryNodesCreated',4);return view;
|
||
}
|
||
function createInventoryItemView(item,cursor){
|
||
if(cursor){
|
||
const option=document.createElement('button');option.type='button';option.className='inventory-cursor-option';option.dataset.itemId=item.id;option.addEventListener('click',event=>{event.preventDefault();void useInventoryItem(option.dataset.itemId)});
|
||
const view={root:option,option,cursor:true,itemId:item.id};inventoryItemViews.set(item.id,view);perfCount('inventoryNodesCreated');return view;
|
||
}
|
||
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';card.dataset.itemId=item.id;icon.className='inventory-item-icon';copy.append(name,description);use.type='button';use.className='inventory-use';use.addEventListener('click',()=>void useInventoryItem(card.dataset.itemId));card.append(icon,copy,use);
|
||
const view={root:card,card,icon,name,description,use,cursor:false,itemId:item.id};inventoryItemViews.set(item.id,view);perfCount('inventoryNodesCreated',6);return view;
|
||
}
|
||
function inventoryItemViewSignature(item,{count}){
|
||
return item.cursorStyle?`cursor:${data.cursorStyle===item.cursorStyle}`:`item:${count}:${storeItemActive(item)}`;
|
||
}
|
||
function updateInventoryItemView(view,item,state){
|
||
const signature=inventoryItemViewSignature(item,state);if(view.signature===signature){perfCount('inventoryItemsUnchanged');return false}
|
||
view.signature=signature;const{count}=state;
|
||
if(view.cursor){
|
||
const option=view.option,selected=data.cursorStyle===item.cursorStyle;setItemIcon(option,item);option.classList.toggle('selected',selected);option.setAttribute('aria-pressed',String(selected));option.setAttribute('aria-label',`${item.name}カーソル`);perfCount('inventoryItemsPatched');return true;
|
||
}
|
||
const active=storeItemActive(item);view.card.classList.remove('debug-available');setItemIcon(view.icon,item);view.name.textContent=`${item.name} · ×${count}`;view.description.textContent=item.description;
|
||
view.use.textContent=item.scoreLens?(active?'オン':'オフ'):(active?'装備中':'装備する');view.use.disabled=false;
|
||
const pressed=Boolean(item.scoreLens||item.lineColor||item.lineEffect||item.reactionStyle);view.use.classList.toggle('selected',pressed&&active);if(pressed)view.use.setAttribute('aria-pressed',String(active));else view.use.removeAttribute('aria-pressed');perfCount('inventoryItemsPatched');return true;
|
||
}
|
||
function patchInventoryItems(itemIds){
|
||
if(!inventoryHasRendered||!inventoryModal.classList.contains('show'))return 0;const started=perfStart(),scrollPosition=panelScrollPosition(inventoryPanel),debug=debugAllItemsEnabled();let patched=0;
|
||
for(const itemId of new Set(itemIds||[])){if(!itemId)continue;const view=inventoryItemViews.get(itemId),item=storeItem(itemId);if(view&&item&&updateInventoryItemView(view,item,{debug,count:inventoryCount(item.id)}))patched++}
|
||
restorePanelScroll(inventoryPanel,scrollPosition);perfGauge('inventoryLastPatchItems',patched);perfEnd('inventoryPatch',started);return patched;
|
||
}
|
||
function placeInventoryNode(parent,node,anchor){
|
||
if(node===anchor)return anchor.nextElementSibling;parent.insertBefore(node,anchor);return anchor;
|
||
}
|
||
function renderInventoryPanel(){
|
||
const started=perfStart(),scrollPosition=panelScrollPosition(inventoryPanel);rememberInventoryCategoryState();
|
||
const debug=debugAllItemsEnabled(),total=inventoryCount();
|
||
inventoryCountEl.textContent=String(total);
|
||
inventoryTotal.textContent=String(total);
|
||
inventoryTarget.textContent='カテゴリ名を押すと折り畳めます。ラインカラー、エフェクト、カーソルをここで装備できます。';
|
||
if(!total){
|
||
let empty=inventoryList.querySelector(':scope > .inventory-item.empty');if(!empty){empty=document.createElement('div');empty.className='inventory-item empty';empty.textContent='所持アイテムはありません。';perfCount('inventoryNodesCreated')}
|
||
inventoryList.replaceChildren(empty);for(const view of inventoryItemViews.values())view.root.remove();inventoryItemViews.clear();for(const view of inventoryCategoryViews.values())view.section.remove();inventoryCategoryViews.clear();inventorySelectedCursorItemId=null;restorePanelScroll(inventoryPanel,scrollPosition);perfGauge('inventoryMountedItems',0);perfEnd(inventoryHasRendered?'inventoryPatch':'inventoryRender',started);inventoryHasRendered=true;return;
|
||
}
|
||
inventoryList.querySelector(':scope > .inventory-item.empty')?.remove();
|
||
const available=STORE_ITEMS.filter(item=>inventoryCount(item.id)>0),categories=[
|
||
{key:'line-colors',title:'ラインカラー',items:available.filter(item=>item.lineColor),cursor:false},
|
||
{key:'reactions',title:'リアクション',items:available.filter(item=>item.reactionStyle),cursor:false},
|
||
{key:'tools',title:'ツール',items:available.filter(item=>item.scoreLens),cursor:false},
|
||
{key:'cursors',title:'カーソル',items:available.filter(item=>item.cursorStyle),cursor:true}
|
||
];
|
||
const availableIds=new Set(available.map(item=>item.id)),categoryKeys=new Set(),desiredSections=[];
|
||
for(const category of categories){
|
||
if(!category.items.length)continue;categoryKeys.add(category.key);
|
||
const categoryView=inventoryCategoryViews.get(category.key)||createInventoryCategoryView(category);desiredSections.push(categoryView.section);if(categoryView.titleText.nodeValue!==category.title)categoryView.titleText.nodeValue=category.title;const categoryCount=String(category.items.length);if(categoryView.countBadge.textContent!==categoryCount)categoryView.countBadge.textContent=categoryCount;
|
||
let itemAnchor=categoryView.list.firstElementChild;
|
||
for(const item of category.items){
|
||
let itemView=inventoryItemViews.get(item.id);if(!itemView||itemView.cursor!==category.cursor){itemView?.root?.remove?.();inventoryItemViews.delete(item.id);itemView=createInventoryItemView(item,category.cursor)}else perfCount('inventoryNodesReused');
|
||
updateInventoryItemView(itemView,item,{debug,count:inventoryCount(item.id)});itemAnchor=placeInventoryNode(categoryView.list,itemView.root,itemAnchor);
|
||
}
|
||
}
|
||
for(const[id,view]of[...inventoryItemViews])if(!availableIds.has(id)){view.root.remove();inventoryItemViews.delete(id);perfCount('inventoryNodesRemoved')}
|
||
for(const[key,view]of[...inventoryCategoryViews])if(!categoryKeys.has(key)){view.section.remove();inventoryCategoryViews.delete(key);perfCount('inventoryNodesRemoved',4)}
|
||
let sectionAnchor=inventoryList.firstElementChild;for(const section of desiredSections)sectionAnchor=placeInventoryNode(inventoryList,section,sectionAnchor);
|
||
inventorySelectedCursorItemId=available.find(item=>item.cursorStyle&&item.cursorStyle===data.cursorStyle)?.id||null;
|
||
restorePanelScroll(inventoryPanel,scrollPosition);perfGauge('inventoryMountedItems',inventoryItemViews.size);perfEnd(inventoryHasRendered?'inventoryPatch':'inventoryRender',started);inventoryHasRendered=true;
|
||
}
|
||
function updateInventoryUi(){
|
||
inventoryCountEl.textContent=debugAllItemsEnabled()?'∞':String(inventoryCount());
|
||
if(inventoryModal.classList.contains('show'))renderInventoryPanel();
|
||
}
|
||
function syncInventoryCursorSelection(){
|
||
if(!inventoryList)return;const next=STORE_ITEMS.find(item=>item.cursorStyle&&item.cursorStyle===data.cursorStyle)?.id||null;patchInventoryItems([inventorySelectedCursorItemId,next]);inventorySelectedCursorItemId=next;
|
||
}
|
||
function activeLineColorItem(){return storeItem(data.lineColorStyle)||storeItem(data.starterLineColor)||LINE_COLOR_ITEMS[0]}
|
||
function activeLineColorIndex(){const id=activeLineColorItem()?.id,index=LINE_COLOR_ITEMS.findIndex(item=>item.id===id);return index>=0?index:0}
|
||
const AURORA_COLOR_INTERVAL=2000,AURORA_RGB_PALETTE=Object.freeze(['79 235 255','126 255 188','255 223 105','255 112 207','171 126 255','105 160 255','255 139 92']);
|
||
let auroraRgbTimer=0,auroraNextTickAt=0,auroraVisiblePathCount=0,auroraRgbValue='',auroraPaletteIndex=-1;
|
||
function nextAuroraRgb(){auroraPaletteIndex=(auroraPaletteIndex+1)%AURORA_RGB_PALETTE.length;return AURORA_RGB_PALETTE[auroraPaletteIndex]}
|
||
function auroraColorHost(){return world||document.documentElement}
|
||
function writeAuroraRgb(){
|
||
const next=nextAuroraRgb();if(next===auroraRgbValue)return false;auroraRgbValue=next;auroraColorHost().style.setProperty('--aurora-rgb',next);perfCount('auroraColorWrites');return true;
|
||
}
|
||
function stopAuroraRgbAnimation(resetDeadline=true){
|
||
if(auroraRgbTimer)clearTimeout(auroraRgbTimer);auroraRgbTimer=0;if(resetDeadline)auroraNextTickAt=0;
|
||
}
|
||
function scheduleAuroraRgbTick(){
|
||
if(auroraRgbTimer||document.visibilityState==='hidden'||auroraVisiblePathCount<=0)return false;
|
||
const now=perfNow();if(!auroraNextTickAt)auroraNextTickAt=now+AURORA_COLOR_INTERVAL;
|
||
auroraRgbTimer=setTimeout(()=>{
|
||
auroraRgbTimer=0;const started=perfStart();if(document.visibilityState==='hidden'||auroraVisiblePathCount<=0){stopAuroraRgbAnimation();perfEnd('auroraTick',started);return}
|
||
const tickNow=perfNow();writeAuroraRgb();do{auroraNextTickAt+=AURORA_COLOR_INTERVAL}while(auroraNextTickAt<=tickNow);perfEnd('auroraTick',started);scheduleAuroraRgbTick();
|
||
},Math.max(0,auroraNextTickAt-now));return true;
|
||
}
|
||
function startAuroraRgbAnimation(){
|
||
if(AURORA_COLOR_INTERVAL<=0||document.visibilityState==='hidden'||auroraVisiblePathCount<=0)return false;
|
||
if(!auroraRgbValue)writeAuroraRgb();scheduleAuroraRgbTick();return true;
|
||
}
|
||
function updateAuroraAnimationState(){
|
||
perfGauge('auroraActivePaths',auroraVisiblePathCount);if(document.visibilityState==='hidden'||auroraVisiblePathCount<=0){stopAuroraRgbAnimation();return false}return startAuroraRgbAnimation();
|
||
}
|
||
function updateBoardAuroraPathCount(board){
|
||
if(!board)return 0;const nodes=[...(board.pathStrokeNodes||[]),...(board.connectorNodes||[]),...(board.gateDots||[]),...(board.gateKnobs||[]),...(board.gateMarkers||[])],next=nodes.reduce((count,node)=>count+(node?.classList?.contains('line-effect-aurora')?1:0),0),previous=Number(board.auroraPathCount)||0;
|
||
if(next!==previous){board.auroraPathCount=next;auroraVisiblePathCount=Math.max(0,auroraVisiblePathCount+next-previous);updateAuroraAnimationState()}return next;
|
||
}
|
||
function releaseBoardAuroraPathCount(board){
|
||
const previous=Number(board?.auroraPathCount)||0;if(!previous)return;board.auroraPathCount=0;auroraVisiblePathCount=Math.max(0,auroraVisiblePathCount-previous);updateAuroraAnimationState();
|
||
}
|
||
function syncCosmeticAppearance(){
|
||
normalizeEquippedCosmeticsInPlace(data);const activeColor=activeLineColorItem(),color=activeColor?.lineColor||LINE_COLORS[0],lineEffect=activeColor?.aurora?'aurora':'none',reactionStyle=REACTION_STYLE_IDS.has(data.reactionStyle)?data.reactionStyle:'classic';
|
||
document.body.style.setProperty('--player-line-color',color);document.body.dataset.lineEffect=lineEffect;document.body.dataset.reactionStyle=reactionStyle;updateAuroraAnimationState();prewarmReactionGlyphs(data.lastReaction||REACTION_EMOJIS[0],reactionStyle);
|
||
}
|
||
|
||
function syncCursorAppearance(style){
|
||
const selected=cursorModel.item(style),presentation=cursorModel.presentation(style);
|
||
document.body.dataset.cursorStyle=presentation.style;document.body.dataset.cursorEmoji=selected?'on':'off';document.body.dataset.cursorMode=presentation.mode;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){customEmojiCursor.classList.remove('visible');customEmojiCursor.style.transform=''}
|
||
syncPickupHandleDesign();
|
||
}
|
||
function applyCursorStyle(style){data.cursorStyle=style||'default';syncCursorAppearance(data.cursorStyle);if(realtimePendingCursor){realtimePendingCursor.cursorStyle=data.cursorStyle;realtimeSend({type:'cursor',...realtimePendingCursor})}markGlobalDirty();}
|
||
let customCursorX=0,customCursorY=0,customCursorInputAt=0,customCursorInputRevision=0,customCursorCommittedRevision=0,customCursorFrame=0,customCursorLastDraw=0;
|
||
function scheduleCustomCursorFrame(){
|
||
if(customCursorFrame)return;
|
||
const paint=timestamp=>{
|
||
if(customCursorLastDraw&×tamp+INTERACTION_FRAME_TOLERANCE_MS<customCursorLastDraw+DRAG_FRAME_INTERVAL){customCursorFrame=requestAnimationFrame(paint);return}
|
||
customCursorFrame=0;customCursorLastDraw=timestamp;
|
||
commitCustomCursorFrame({x:customCursorX,y:customCursorY,inputAt:customCursorInputAt,revision:customCursorInputRevision},timestamp);
|
||
if(customCursorCommittedRevision!==customCursorInputRevision)scheduleCustomCursorFrame();
|
||
};
|
||
customCursorFrame=requestAnimationFrame(paint);
|
||
}
|
||
function cancelCustomCursorFrame(){
|
||
if(customCursorFrame)cancelAnimationFrame(customCursorFrame);
|
||
customCursorFrame=0;customCursorLastDraw=0;
|
||
}
|
||
function commitCustomCursorFrame(sample,timestamp){
|
||
const customActive=document.body.dataset.cursorMode==='dom'&&!document.body.classList.contains('is-drawing');
|
||
if(!customActive){customCursorCommittedRevision=sample.revision;customEmojiCursor.classList.remove('visible');return}
|
||
customEmojiCursor.style.transform=`translate3d(${sample.x}px,${sample.y}px,0) translate(-50%,-50%)`;customEmojiCursor.classList.add('visible');
|
||
recordInteractionCommit('cursor',timestamp,sample.inputAt,true);customCursorCommittedRevision=sample.revision;markVisualFrame(timestamp);perfCount('cursorCommits');perfCount('cursorStyleMutations');
|
||
}
|
||
function updateCustomCursorFromPointer(event){
|
||
const customActive=document.body.dataset.cursorMode==='dom'&&!document.body.classList.contains('is-drawing'),insideDocument=event.target?.isConnected!==false,finePointer=event.pointerType!=='touch';
|
||
if(!customActive||!insideDocument||!finePointer){cancelCustomCursorFrame();customEmojiCursor.classList.remove('visible');return}
|
||
customCursorX=event.clientX;customCursorY=event.clientY;customCursorInputAt=Number(event.timeStamp)||perfNow();customCursorInputRevision++;
|
||
scheduleCustomCursorFrame();
|
||
}
|
||
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&&!ownsStoreItem(itemId))){toast('そのアイテムは所持していません。');renderInventoryPanel();return false}
|
||
if(item.cursorStyle){
|
||
const previousCursor=data.cursorStyle,nextCursor=previousCursor===item.cursorStyle?'default':item.cursorStyle;
|
||
applyCursorStyle(nextCursor);syncInventoryCursorSelection();
|
||
if(!await save(true)){applyCursorStyle(previousCursor);syncInventoryCursorSelection();toast('切り替えできませんでした。');return false}
|
||
toast(nextCursor==='default'?'標準カーソルへ戻しました。':`${item.name}へ切り替えました。`);return true;
|
||
}
|
||
if(item.lineColor){
|
||
const previous=data.lineColorStyle;data.lineColorStyle=item.id;syncCosmeticAppearance();renderAll();markGlobalDirty();
|
||
if(!await save(true)){data.lineColorStyle=previous;syncCosmeticAppearance();renderAll();markGlobalDirty();patchInventoryItems([previous,item.id]);toast('ラインカラーを変更できませんでした。');return false}
|
||
patchInventoryItems([previous,item.id]);toast(`${item.name}を装備しました。新しく引く線に適用されます。`);return true;
|
||
}
|
||
if(item.lineEffect){
|
||
const previous=data.lineEffectStyle;data.lineEffectStyle=previous===item.lineEffect?'none':item.lineEffect;syncCosmeticAppearance();markGlobalDirty();
|
||
const previousItemId=inventoryLineEffectItemIds.get(previous)||null,nextItemId=inventoryLineEffectItemIds.get(data.lineEffectStyle)||null;
|
||
if(!await save(true)){data.lineEffectStyle=previous;syncCosmeticAppearance();markGlobalDirty();patchInventoryItems([previousItemId,nextItemId]);toast('ラインエフェクトを変更できませんでした。');return false}
|
||
patchInventoryItems([previousItemId,nextItemId]);toast(data.lineEffectStyle==='none'?'ラインエフェクトを外しました。':`${item.name}を装備しました。`);return true;
|
||
}
|
||
if(item.reactionStyle){
|
||
const previous=data.reactionStyle;data.reactionStyle=previous===item.reactionStyle?'classic':item.reactionStyle;syncCosmeticAppearance();markGlobalDirty();
|
||
const previousItemId=inventoryReactionStyleItemIds.get(previous)||null,nextItemId=inventoryReactionStyleItemIds.get(data.reactionStyle)||null;
|
||
if(!await save(true)){data.reactionStyle=previous;syncCosmeticAppearance();markGlobalDirty();patchInventoryItems([previousItemId,nextItemId]);toast('リアクションを変更できませんでした。');return false}
|
||
patchInventoryItems([previousItemId,nextItemId]);toast(data.reactionStyle==='classic'?'標準の絵文字表示に戻しました。':`${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();patchInventoryItems([item.id]);toast('切り替えできませんでした。');return false}
|
||
updateHud();renderAll();updateZoomPresentation(true);patchInventoryItems([item.id]);toast(`${item.name} ${data.scoreLensEnabled?'オン':'オフ'}`);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 waitForTimeAttackCountdown(milliseconds){return new Promise(resolve=>setTimeout(resolve,milliseconds))}
|
||
function showTimeAttackCountdownOverlay(value,mode){
|
||
clearTimeout(timeAttackCountdownHideTimer);timeAttackCountdownHideTimer=0;
|
||
timeAttackCountdownOverlay.hidden=false;timeAttackCountdownOverlay.className='';timeAttackCountdownValue.textContent=String(value);
|
||
void timeAttackCountdownOverlay.offsetWidth;
|
||
timeAttackCountdownOverlay.classList.add(mode==='final'?'final-sequence':'start-sequence');
|
||
if(value==='Start')timeAttackCountdownOverlay.classList.add('is-start');
|
||
}
|
||
function hideTimeAttackCountdownOverlay(resetFinal=false){
|
||
clearTimeout(timeAttackCountdownHideTimer);timeAttackCountdownHideTimer=0;timeAttackCountdownOverlay.hidden=true;timeAttackCountdownOverlay.className='';
|
||
if(resetFinal)timeAttackFinalCountdownSecond=null;
|
||
}
|
||
async function playTimeAttackStartLeadIn(){
|
||
timeAttackCountdownActive=true;timeAttackFinalCountdownSecond=null;document.activeElement?.blur?.();
|
||
for(const value of['3','2','1']){showTimeAttackCountdownOverlay(value,'start');await waitForTimeAttackCountdown(850)}
|
||
}
|
||
function releaseTimeAttackStartOverlay(){timeAttackCountdownActive=false;hideTimeAttackCountdownOverlay()}
|
||
function pulseTimeAttackFinalCountdown(seconds){
|
||
if(timeAttackCountdownActive||seconds===timeAttackFinalCountdownSecond)return;
|
||
timeAttackFinalCountdownSecond=seconds;showTimeAttackCountdownOverlay(seconds,'final');
|
||
timeAttackCountdownHideTimer=setTimeout(()=>hideTimeAttackCountdownOverlay(),540);
|
||
}
|
||
function timeAttackResultText(result=data.lastTimeAttack){
|
||
if(!result)return'';
|
||
return[
|
||
'⏱️ LinkField/リンクフィールド|タイムアタック',
|
||
`🏁 ${result.durationMinutes}分 🧩 ${result.solves}枚クリア`,
|
||
`🏆 合計 ${formatScore(result.total)}`,
|
||
cloudAppBaseUrl
|
||
].join('\n');
|
||
}
|
||
function timeAttackCooldownRemaining(_durationMinutes,now=trustedNow()){
|
||
const sharedEndsAt=Math.max(0,...TIME_ATTACK_MINUTES.map(minutes=>Number(data.timeAttackCooldowns?.[minutes])||0));
|
||
return Math.max(0,sharedEndsAt-now);
|
||
}
|
||
function hasActiveTimeAttackCooldown(){return timeAttackCooldownRemaining(TIME_ATTACK_MINUTES[0])>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?`\u5408\u8a08 ${formatScore(last.total)}`:'\u6311\u6226\u7d50\u679c';
|
||
}
|
||
if(last){
|
||
timeAttackResultLimit.textContent=`${last.durationMinutes}\u5206`;
|
||
timeAttackResultSolves.textContent=String(last.solves);
|
||
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){hideTimeAttackCountdownOverlay(true);void finishTimeAttack();return}
|
||
if(run){
|
||
const remaining=Math.max(0,run.endsAt-now),seconds=Math.ceil(remaining/1000);
|
||
timeAttackBtn.classList.toggle('final-countdown',remaining<=30000&&remaining>0);
|
||
if(!timeAttackCountdownActive){timeAttackFinalCountdownSecond=null;hideTimeAttackCountdownOverlay()}
|
||
}else{timeAttackBtn.classList.remove('final-countdown');if(!timeAttackCountdownActive)hideTimeAttackCountdownOverlay(true);}
|
||
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||timeAttackCountdownActive||!TIME_ATTACK_MINUTES.includes(durationMinutes))return false;
|
||
const cooldown=timeAttackCooldownRemaining(durationMinutes);
|
||
if(cooldown){toast(`\u518d\u6311\u6226\u307e\u3067\uff1a${formatTimeAttackClock(cooldown)}`);return false}
|
||
closeTimeAttack(false);
|
||
await playTimeAttackStartLeadIn();
|
||
const refreshedCooldown=timeAttackCooldownRemaining(durationMinutes);
|
||
if(data.timeAttack||refreshedCooldown){releaseTimeAttackStartOverlay();toast(data.timeAttack?'\u5225\u306e\u30bf\u30a4\u30e0\u30a2\u30bf\u30c3\u30af\u304c\u958b\u59cb\u3055\u308c\u307e\u3057\u305f\u3002':`\u518d\u6311\u6226\u307e\u3067\uff1a${formatTimeAttackClock(refreshedCooldown)}`);return false}
|
||
showTimeAttackCountdownOverlay('Start','start');
|
||
await waitForTimeAttackCountdown(700);
|
||
releaseTimeAttackStartOverlay();
|
||
const startedAt=trustedNow(),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();resumeTimeAttackTimer();updateHud();
|
||
if(!await save(true)){data.timeAttack=previous.timeAttack;data.timeAttackRev=previous.timeAttackRev;releaseTimeAttackStartOverlay();resumeTimeAttackTimer();toast('\u30bf\u30a4\u30e0\u30a2\u30bf\u30c3\u30af\u3092\u958b\u59cb\u3067\u304d\u307e\u305b\u3093\u3002');return false}
|
||
toast(`${durationMinutes}\u5206\u306e\u30bf\u30a4\u30e0\u30a2\u30bf\u30c3\u30af\u3092\u958b\u59cb\u3057\u307e\u3057\u305f\u3002`);
|
||
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);const cooldownEndsAt=result.completedAt+TIME_ATTACK_COOLDOWN_MINUTES[result.durationMinutes]*60000;for(const minutes of TIME_ATTACK_MINUTES)data.timeAttackCooldowns[minutes]=cooldownEndsAt;
|
||
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}
|
||
hideTimeAttackCountdownOverlay(true);updateHud();resumeTimeAttackTimer();openTimeAttack();toast(`\u30bf\u30a4\u30e0\u30a2\u30bf\u30c3\u30af\u7d42\u4e86 \u00b7 \u5408\u8a08 ${formatScore(result.total)}`,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 closeSettingsDialog(restoreFocus=true){closeDialogRoot(settingsModal,restoreFocus?(settingsBtn||playerNameBtn):viewport);settingsBtn?.setAttribute('aria-expanded','false')}
|
||
function resetSettingsForm(){settingsPlayerName.value=createAutomaticPlayerName();lightweightRenderingToggle.checked=false;soundEnabledToggle.checked=true;toast('設定内容を初期値に戻しました。閉じると反映されます。');settingsPlayerName.focus()}
|
||
async function saveSettings(restoreFocus=true){
|
||
const name=normalizePlayerNameInput(settingsPlayerName.value);if(!name){toast('プレイヤー名を入力してください。');settingsPlayerName.focus();return false}
|
||
const previousName=currentPlayerName(),previousLightweight=uiSettings.lightweightRendering,nameChanged=name!==previousName;
|
||
uiSettings=normalizeUiSettings({lightweightRendering:lightweightRenderingToggle.checked,soundEnabled:soundEnabledToggle.checked});persistUiSettings();
|
||
if(previousLightweight!==uiSettings.lightweightRendering){for(const board of rendered.values())board.solvedPathsRendered=false;renderAll()}
|
||
closeSettingsDialog(restoreFocus);toast('設定を反映しました。');
|
||
if(nameChanged)try{await commitPlayerProfileName(name)}catch(error){toast('共有プロフィールを更新できませんでした。')}
|
||
return true
|
||
}
|
||
function closeSettings(restoreFocus=true){return saveSettings(restoreFocus)}
|
||
|
||
function purchaseFlyOrigin(sourceElement){
|
||
const rect=sourceElement?.getBoundingClientRect?.();
|
||
return rect&&rect.width>0&&rect.height>0?{left:rect.left,top:rect.top,width:rect.width,height:rect.height}:null;
|
||
}
|
||
function animatePurchasedItemToInventory(item,origin){
|
||
if(!item||!origin||!inventoryBtn?.isConnected)return;
|
||
const target=inventoryBtn.getBoundingClientRect(),fly=document.createElement('div');
|
||
fly.className='purchase-item-fly';setItemIcon(fly,item);fly.setAttribute('aria-hidden','true');
|
||
const startX=origin.left+origin.width/2,startY=origin.top+origin.height/2,endX=target.left+target.width/2,endY=target.top+target.height/2;
|
||
fly.style.left=`${startX}px`;fly.style.top=`${startY}px`;document.body.append(fly);
|
||
const dx=endX-startX,dy=endY-startY,arc=-Math.max(42,Math.min(150,Math.abs(dx)*.18+Math.abs(dy)*.16));
|
||
if(typeof fly.animate!=='function'){fly.style.transform=`translate(calc(-50% + ${dx}px),calc(-50% + ${dy}px)) scale(.28)`;setTimeout(()=>{fly.remove();inventoryBtn.classList.add('purchase-arrival');setTimeout(()=>inventoryBtn.classList.remove('purchase-arrival'),420)},620);return}
|
||
const animation=fly.animate([
|
||
{transform:'translate(-50%,-50%) scale(1) rotate(0deg)',opacity:1,offset:0},
|
||
{transform:`translate(calc(-50% + ${dx*.48}px),calc(-50% + ${dy*.48+arc}px)) scale(1.18) rotate(12deg)`,opacity:1,offset:.52},
|
||
{transform:`translate(calc(-50% + ${dx}px),calc(-50% + ${dy}px)) scale(.28) rotate(34deg)`,opacity:.1,offset:1}
|
||
],{duration:620,easing:'cubic-bezier(.18,.78,.18,1)',fill:'forwards'});
|
||
inventoryBtn.classList.remove('purchase-arrival');
|
||
animation.finished.then(()=>{fly.remove();inventoryBtn.classList.add('purchase-arrival');setTimeout(()=>inventoryBtn.classList.remove('purchase-arrival'),420)}).catch(()=>fly.remove());
|
||
}
|
||
|
||
function renderStorePanel(){
|
||
const scrollPosition=panelScrollPosition(storePanel);
|
||
pruneAndCount();
|
||
const meta=data.metas[openStoreBoardId],st=meta?metaState(meta.id):null,store=st?.store;
|
||
if(!meta||!store){closeStore(false);return}
|
||
storeTitle.textContent='アイテムショップ';
|
||
storeWallet.textContent=debugAllItemsEnabled()?'∞':formatScore(data.score);
|
||
storeMeta.textContent=`店主:${store.owner}`;
|
||
storeInventory.replaceChildren();
|
||
const available=storeInventoryItems(meta,store),categories=[
|
||
{title:'カーソル',items:available.filter(item=>item.cursorStyle),compact:true,cursor:true,description:'盤面操作に使うカーソルデザインです。'},
|
||
{title:'その他のアイテム',items:available.filter(item=>!item.cursorStyle).slice(0,6),compact:false,cursor:false,description:''}
|
||
];
|
||
for(const category of categories){
|
||
if(!category.items.length)continue;
|
||
const section=document.createElement('section'),title=document.createElement('h3'),brief=document.createElement('p'),list=document.createElement('div');
|
||
section.className=`store-section store-${category.cursor?'cursors':'items'}-section`;title.className='store-section-title';title.textContent=category.title;brief.className='store-section-brief';brief.textContent=category.description;
|
||
list.className=`store-section-list ${category.compact?'store-compact-list':'store-item-list'}${category.cursor?' store-cursor-list':''}`;
|
||
for(const item of category.items){
|
||
const purchased=personalEconomyMode()?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'}${category.compact?' store-compact':''}${purchased?' purchased':''}`;
|
||
card.title=`${item.name} — ${item.effectLabel}:${item.description}`;icon.className='store-item-icon';setItemIcon(icon,item);
|
||
buy.type='button';buy.className='store-buy';
|
||
buy.textContent=purchased?'購入済み':`購入 ${formatScore(price)}`;
|
||
buy.disabled=purchased||(!debugAllItemsEnabled()&&data.score<price)||(!debugAllItemsEnabled()&&personalEconomyMode()&&!onlinePlayerEconomy());buy.setAttribute('aria-label',`${item.name}:${buy.textContent}`);
|
||
buy.addEventListener('click',()=>purchaseStoreItem(meta.id,item.id,purchaseFlyOrigin(icon)));
|
||
if(category.compact)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);if(category.description)section.append(brief);section.append(list);storeInventory.append(section);
|
||
}
|
||
restorePanelScroll(storePanel,scrollPosition);
|
||
}
|
||
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,purchaseOrigin=null){
|
||
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 debug=debugAllItemsEnabled();if(!debug&&personalEconomyMode()&&!onlinePlayerEconomy()){toast('共有の所持数を確認できないため購入できません。');renderStorePanel();return false}
|
||
const already=personalEconomyMode()?playerPurchaseForStore(boardId,itemId):store.purchases.find(purchase=>purchase.id===item.id);if(already){toast('購入済みです。');return false}
|
||
const price=storeItemPrice(meta,store,item);if(!debug&&data.score<price){toast(`所持数が不足しています。価格 ${formatScore(price)}`);renderStorePanel();return false}
|
||
if(debug&&personalEconomyMode()){const purchase={purchaseId:`debug-${sessionId}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,8)}`,boardId,itemId:item.id,buyer:currentPlayerName(),boughtAt:trustedNow(),paidCost:0};data.playerPurchases=[...normalizePlayerPurchases(data.playerPurchases),purchase];invalidateEconomyCaches();markGlobalDirty(false);if(!await save(true)){data.playerPurchases=data.playerPurchases.filter(entry=>entry.purchaseId!==purchase.purchaseId);invalidateEconomyCaches();toast('購入できませんでした。');return false}updateHud();renderStorePanel();animatePurchasedItemToInventory(item,purchaseOrigin);playSound('buy');toast(`${item.name}を購入しました。`);return true}
|
||
if(onlinePlayerEconomy())try{await buyPersonalStoreItem(boardId,itemId);updateHud();renderStorePanel();animatePurchasedItemToInventory(item,purchaseOrigin);playSound('buy');toast(`${item.name}を購入しました。`);return true}catch(error){toast('購入できませんでした。共有状態を確認してください。');renderStorePanel();return false}
|
||
const previousRev=st.rev,purchase={id:item.id,buyer:currentPlayerName(),boughtAt:trustedNow(),paidCost:debug?0: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();animatePurchasedItemToInventory(item,purchaseOrigin);playSound('buy');toast(`${item.name}を購入しました。`);return true;
|
||
}
|
||
window.addEventListener('keydown',e=>{
|
||
if(settingsModal.classList.contains('show')){
|
||
if(e.key==='Escape'){e.preventDefault();void closeSettings();return}
|
||
if(e.key==='Enter'&&document.activeElement===settingsPlayerName){e.preventDefault();void closeSettings();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;
|
||
resetSettingsBtn.onclick=resetSettingsForm;
|
||
closeSettingsBtn.onclick=()=>{void closeSettings()};
|
||
settingsModal.addEventListener('pointerdown',e=>{if(e.target===settingsModal)void closeSettings()});
|
||
inventoryBtn.onclick=openInventory;
|
||
document.querySelector('#closeInventory').onclick=()=>closeInventory();
|
||
inventoryModal.addEventListener('pointerdown',e=>{if(e.target===inventoryModal)closeInventory()});
|
||
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('shop');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:normalizeSpecialMechanics(source.specialMechanicsSeen),
|
||
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()}
|
||
}
|
||
if(exportBtn)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();
|
||
}
|
||
const freshStartButton=document.querySelector('#freshBtn');if(freshStartButton)freshStartButton.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=normalizeSpecialMechanics(raw?.specialMechanicsSeen);
|
||
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')}};
|
||
}
|
||
document.querySelector('#retryBtn').onclick=()=>{void runStatusRetry()};
|
||
function announceWorldReplacement(){}
|
||
function checkpointForLifecycle(){if(lifecyclePersistenceSuppressed)return;writeDirtyRecoveryJournal();void flushSave({lifecycle:true})}
|
||
window.addEventListener('pagehide',checkpointForLifecycle);
|
||
window.addEventListener('pagehide',()=>{skipCompletionVisuals();cleanupGemEffects();cancelReactionRenderScheduler(true);stopAuroraRgbAnimation()});
|
||
document.addEventListener('visibilitychange',()=>{const hidden=document.visibilityState==='hidden';document.body.classList.toggle('effects-paused',hidden);if(hidden){cancelReactionGesture();hideRealtimeCursor();cancelReactionRenderScheduler(true);stopAuroraRgbAnimation();clearTimeout(realtimeHeartbeatTimer);realtimeHeartbeatTimer=0;clearTimeout(realtimePollTimer);realtimePollTimer=0;pauseNoiseBackground();checkpointForLifecycle();void pushCloudPending()}else{scheduleNoiseBackground(true);resumeTimeAttackTimer();connectRealtime();scheduleRealtimeViewport(true);scheduleRealtimeHeartbeat();schedulePresenceRender(true);updateAuroraAnimationState();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,remoteWorldPullTimer=0,remoteWorldRevision=0,lastCloudWorldGlobalSignature='';
|
||
const remotePlayers=new Map(),boardClaims=new Map(),realtimeClaimRequests=new Map(),remoteCursorImageCache=new Map(),realtimeHeldPointers=new Set(),realtimeReactions=new Map(),pendingRealtimeReactionMessages=[];
|
||
let realtimeSocket=null,realtimeReady=false,realtimePresenceId=null,realtimeClaimTtlMs=5*60*1000,realtimeReconnectTimer=0,realtimeViewportTimer=0,realtimeCursorTimer=0,realtimeHeartbeatTimer=0,realtimePollTimer=0,realtimePollSequence=0,realtimeTransport='none',realtimeHttpConnecting=false,realtimeHttpQueue=Promise.resolve(),realtimeWebSocketFailures=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,reactionWatchdogTimer=0,reactionCalibrationFrame=0,reactionVsyncInterval=1000/60,reactionVsyncCalibrated=false,reactionLastDraw=0,reactionLastMetricFrame=0,reactionDirty=true,reactionCameraSnapshot=null,reactionGesture=null,reactionSequence=0;
|
||
function realtimeWebSocketUrl(){const endpoint=new URL(cloudEndpointUrl('/api/realtime'));endpoint.protocol=endpoint.protocol==='https:'?'wss:':'ws:';return endpoint.href}
|
||
function enqueueRealtimeHttp(task){const run=realtimeHttpQueue.then(task,task);realtimeHttpQueue=run.then(()=>undefined,()=>undefined);return run}
|
||
function applyRealtimeHttpEnvelope(envelope){if(!envelope||typeof envelope!=='object')return false;if(Number.isFinite(envelope.serverTime))serverClockOffset=envelope.serverTime-Date.now();if(Number.isFinite(envelope.sequence))realtimePollSequence=Math.max(0,Number(envelope.sequence));for(const message of Array.isArray(envelope.messages)?envelope.messages:[])handleRealtimeMessage(message);if(envelope.eventGap===true&&realtimeReady)realtimeSend({type:'snapshot-request'});return true}
|
||
function scheduleRealtimeHttpPoll(delay=cloudApiUsesPhpBridge()?PHP_REALTIME_POLL_INTERVAL:50){clearTimeout(realtimePollTimer);realtimePollTimer=0;if(realtimeTransport!=='http-poll'||!realtimePresenceId||document.visibilityState==='hidden')return false;realtimePollTimer=setTimeout(()=>{realtimePollTimer=0;void enqueueRealtimeHttp(async()=>{try{const bridge=cloudApiUsesPhpBridge(),query=new URLSearchParams({presenceId:realtimePresenceId,after:String(realtimePollSequence),wait:bridge?'0':'20000'}),result=await fetchJson(`/api/realtime/poll?${query}`,{headers:cloudAuthHeaders()},bridge?5000:25000);applyRealtimeHttpEnvelope(result);scheduleRealtimeHttpPoll(bridge?PHP_REALTIME_POLL_INTERVAL:50)}catch(error){if(error?.status===410){realtimeReady=false;realtimePresenceId=null;realtimeTransport='none'}scheduleRealtimeReconnect(1200)}})},Math.max(25,delay));return true}
|
||
function realtimeSend(message){
|
||
if(!realtimeReady)return false;
|
||
if(realtimeTransport==='http-poll'){
|
||
const presenceId=realtimePresenceId;if(!presenceId)return false;
|
||
void enqueueRealtimeHttp(async()=>{try{const result=await fetchJson('/api/realtime/send',{method:'POST',headers:cloudAuthHeaders(),body:JSON.stringify({presenceId,message,afterSequence:realtimePollSequence})},5000);applyRealtimeHttpEnvelope(result);scheduleRealtimeHttpPoll(250)}catch(error){if(error?.status===410){realtimeReady=false;realtimePresenceId=null;realtimeTransport='none'}scheduleRealtimeReconnect(1200)}});return true;
|
||
}
|
||
if(realtimeSocket?.readyState!==WebSocket.OPEN)return false;try{realtimeSocket.send(JSON.stringify(message));return true}catch(_){return false}
|
||
}
|
||
function disconnectRealtimeForLifecycle(){
|
||
const presenceId=realtimePresenceId,transport=realtimeTransport,socket=realtimeSocket;
|
||
realtimePresenceId=null;realtimeReady=false;realtimeTransport='none';realtimeSocket=null;
|
||
if(transport==='http-poll'&&presenceId&&data.cloudProfile){
|
||
try{void fetch(cloudEndpointUrl('/api/realtime/disconnect'),{method:'POST',headers:{...cloudAuthHeaders(),'content-type':'application/json'},body:JSON.stringify({presenceId}),keepalive:true,cache:'no-store'})}catch(_){}
|
||
}else if(socket)try{socket.close(1000,'Page closed')}catch(_){}
|
||
}
|
||
window.addEventListener('pagehide',disconnectRealtimeForLifecycle);
|
||
function sendOrQueueRealtimeReaction(reaction){
|
||
if(!reaction||reaction.expiresAt<=Date.now())return false;const message={type:'reaction',id:reaction.id,emoji:reaction.emoji,style:reaction.style,x:reaction.x,y:reaction.y};
|
||
if(realtimeSend(message))return true;if(!pendingRealtimeReactionMessages.some(entry=>entry.id===reaction.id))pendingRealtimeReactionMessages.push({...message,expiresAt:reaction.expiresAt});while(pendingRealtimeReactionMessages.length>12)pendingRealtimeReactionMessages.shift();connectRealtime();return false;
|
||
}
|
||
function flushPendingRealtimeReactions(){
|
||
if(!realtimeReady||!pendingRealtimeReactionMessages.length)return 0;const now=Date.now(),pending=pendingRealtimeReactionMessages.splice(0),remaining=[];let sent=0;
|
||
for(const message of pending){if(message.expiresAt<=now)continue;const{expiresAt,...payload}=message;if(realtimeSend(payload))sent++;else remaining.push(message)}pendingRealtimeReactionMessages.push(...remaining);return sent;
|
||
}
|
||
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 transportInterval=realtimeTransport==='http-poll'?(cloudApiUsesPhpBridge()?PHP_REALTIME_CURSOR_INTERVAL:250):REALTIME_CURSOR_INTERVAL,wait=transportInterval-(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=cursorModel.item(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 reactionSeed(id,index=0){let value=0x811c9dc5;for(const character of String(id||''))value=Math.imul(value^character.charCodeAt(0),0x01000193);return hash32(value^Math.imul(index+1,0x9e3779b1))}
|
||
function reactionUnit(seed,index=0){return(reactionSeed(seed,index)>>>0)/0xffffffff}
|
||
function reactionDurationForStyle(style){return style==='comet'?3000:style==='firework'?3400:style==='orbit'?3200:style==='giant'||style==='laser'?2700:1050}
|
||
const REACTION_GLYPH_CACHE_MAX_ENTRIES=160,REACTION_GLYPH_CACHE_MAX_BYTES=12*1024*1024,REACTION_STATIC_PATH_CACHE_MAX_ENTRIES=32,
|
||
REACTION_FONT='"Segoe UI Emoji","Apple Color Emoji","Noto Color Emoji",sans-serif',
|
||
REACTION_LASER_COLORS=Object.freeze(['#55efff','#ff4fd8','#fff56b','#8dff76','#9d75ff','#ff6b53']),
|
||
REACTION_ORBIT_NEBULA_PATH_BUILDER=()=>{const path=new Path2D();path.arc(0,0,250,0,Math.PI*2);return path},
|
||
REACTION_ORBIT_RING_PATH_BUILDERS=Object.freeze(Array.from({length:5},(_,ring)=>()=>{const path=new Path2D();path.ellipse(0,0,118+ring*15,37+ring*8,0,0,Math.PI*2);return path})),
|
||
REACTION_FIREWORK_LAUNCH_PATH_BUILDERS=Object.freeze(Array.from({length:12},(_,index)=>()=>{const p=index/12,path=new Path2D();path.arc(Math.sin(index*2.1)*5,index*12,2.5+(1-p)*2.5,0,Math.PI*2);return path}));
|
||
const reactionGlyphCache=new Map(),reactionStaticPathCache=new Map();
|
||
let reactionGlyphCacheBytes=0,reactionGlyphCacheBypass=0,reactionStaticPathCacheBypass=0,reactionNextDrawAt=0;
|
||
function updateReactionCacheGauges(){
|
||
perfGauge('reactionGlyphCacheEntries',reactionGlyphCache.size);perfGauge('reactionGlyphCacheBytes',reactionGlyphCacheBytes);perfGauge('reactionStaticPathCacheEntries',reactionStaticPathCache.size);
|
||
}
|
||
function clearReactionGlyphCache(){
|
||
reactionGlyphCache.clear();reactionGlyphCacheBytes=0;updateReactionCacheGauges();
|
||
}
|
||
function clearReactionStaticPathCache(){reactionStaticPathCache.clear();updateReactionCacheGauges()}
|
||
function clearReactionEffectCaches(){clearReactionGlyphCache();clearReactionStaticPathCache()}
|
||
function reactionStaticPath(key,builder){
|
||
if(reactionStaticPathCacheBypass||typeof Path2D!=='function')return null;const cached=reactionStaticPathCache.get(key);
|
||
if(cached){reactionStaticPathCache.delete(key);reactionStaticPathCache.set(key,cached);perfCount('reactionStaticPathCacheHits');return cached}
|
||
perfCount('reactionStaticPathCacheMisses');let path;try{path=builder()}catch(_){return null}reactionStaticPathCache.set(key,path);
|
||
while(reactionStaticPathCache.size>REACTION_STATIC_PATH_CACHE_MAX_ENTRIES){const oldestKey=reactionStaticPathCache.keys().next().value;reactionStaticPathCache.delete(oldestKey);perfCount('reactionStaticPathCacheEvictions')}
|
||
updateReactionCacheGauges();return path;
|
||
}
|
||
function reactionOrbitNebulaPath(){return reactionStaticPath('orbit.nebula',REACTION_ORBIT_NEBULA_PATH_BUILDER)}
|
||
function reactionOrbitRingPath(ring){return reactionStaticPath(`orbit.ring.${ring}`,REACTION_ORBIT_RING_PATH_BUILDERS[ring])}
|
||
function reactionFireworkLaunchPath(index){return reactionStaticPath(`firework.launch.${index}`,REACTION_FIREWORK_LAUNCH_PATH_BUILDERS[index])}
|
||
function createReactionGlyphCanvas(width,height){
|
||
if(typeof OffscreenCanvas==='function')return new OffscreenCanvas(width,height);
|
||
const canvas=document.createElement('canvas');canvas.width=width;canvas.height=height;return canvas;
|
||
}
|
||
function reactionGlyphCacheKey(context,emoji,size){
|
||
if(typeof context.fillStyle!=='string'||!Number.isFinite(size)||size<=0)return'';
|
||
return`${emoji}\u0000${size}\u0000${context.fillStyle}\u0000${globalThis.devicePixelRatio||1}`;
|
||
}
|
||
function createReactionGlyphSprite(context,emoji,size){
|
||
const extent=Math.max(32,Math.ceil(size*2.1)),dimension=extent+(extent%2),
|
||
canvas=createReactionGlyphCanvas(dimension,dimension),spriteContext=canvas.getContext('2d',{alpha:true});
|
||
if(!spriteContext)return null;
|
||
spriteContext.translate(dimension/2,dimension/2);spriteContext.font=`${size}px ${REACTION_FONT}`;spriteContext.textAlign='center';spriteContext.textBaseline='middle';
|
||
spriteContext.fillStyle=context.fillStyle;spriteContext.fillText(emoji,0,0);
|
||
return{canvas,width:dimension,height:dimension,bytes:dimension*dimension*4};
|
||
}
|
||
function reactionGlyphSprite(context,emoji,size){
|
||
if(reactionGlyphCacheBypass)return null;
|
||
const key=reactionGlyphCacheKey(context,emoji,size);if(!key)return null;
|
||
const cached=reactionGlyphCache.get(key);
|
||
if(cached){reactionGlyphCache.delete(key);reactionGlyphCache.set(key,cached);perfCount('reactionGlyphCacheHits');return cached}
|
||
perfCount('reactionGlyphCacheMisses');
|
||
const sprite=createReactionGlyphSprite(context,emoji,size);if(!sprite)return null;
|
||
reactionGlyphCache.set(key,sprite);reactionGlyphCacheBytes+=sprite.bytes;
|
||
while(reactionGlyphCache.size>REACTION_GLYPH_CACHE_MAX_ENTRIES||reactionGlyphCacheBytes>REACTION_GLYPH_CACHE_MAX_BYTES){
|
||
const oldestKey=reactionGlyphCache.keys().next().value,oldest=reactionGlyphCache.get(oldestKey);reactionGlyphCache.delete(oldestKey);reactionGlyphCacheBytes=Math.max(0,reactionGlyphCacheBytes-(oldest?.bytes||0));perfCount('reactionGlyphCacheEvictions');
|
||
}
|
||
updateReactionCacheGauges();return sprite;
|
||
}
|
||
function reactionGlyphWarmSizes(style){
|
||
return style==='giant'?[[142,1]]:style==='laser'?[[60,1]]:style==='orbit'?[[24,.78],[27,.78],[30,.78],[33,.78],[112,1]]:style==='firework'?[[21,1],[24,1],[28,1],[38,1]]:style==='comet'?[[52,1],[19,.62],[22,.62],[25,.62],[28,.62]]:[[40,1]];
|
||
}
|
||
function warmReactionGlyphCache(emoji,style){
|
||
if(!REACTION_EMOJIS.includes(emoji))return false;
|
||
const canvas=createReactionGlyphCanvas(1,1),context=canvas.getContext('2d',{alpha:true});if(!context)return false;for(const[size]of reactionGlyphWarmSizes(style))reactionGlyphSprite(context,emoji,size);
|
||
if(style==='orbit'){reactionOrbitNebulaPath();for(let ring=0;ring<5;ring++)reactionOrbitRingPath(ring)}else if(style==='firework')for(let index=0;index<12;index++)reactionFireworkLaunchPath(index);return true;
|
||
}
|
||
function prewarmReactionGlyphs(emoji,style){
|
||
if(!REACTION_EMOJIS.includes(emoji))return;
|
||
const run=()=>warmReactionGlyphCache(emoji,style);
|
||
if(typeof requestIdleCallback==='function')requestIdleCallback(run,{timeout:900});else setTimeout(run,250);
|
||
}
|
||
document.fonts?.addEventListener?.('loadingdone',clearReactionGlyphCache);
|
||
function preparedReactionBurst(seed,count){
|
||
const rays=[];
|
||
for(let index=0;index<count;index++){
|
||
const angle=reactionUnit(seed,index)*Math.PI*2;
|
||
rays.push({cos:Math.cos(angle),sin:Math.sin(angle),radiusFactor:.25+.75*reactionUnit(seed,index+100),innerOffset:18+28*reactionUnit(seed,index+200),hue:Math.round(reactionUnit(seed,index+300)*360),lineWidth:1.5+3*reactionUnit(seed,index+400)});
|
||
}
|
||
return rays;
|
||
}
|
||
function preparedReactionCracks(seed){
|
||
const branches=[];
|
||
for(let branch=0;branch<13;branch++){
|
||
let angle=branch*Math.PI*2/13+(reactionUnit(seed,branch)-.5)*.28;
|
||
const item={x:Math.cos(angle)*28,y:Math.sin(angle)*20,alpha:.42+.45*reactionUnit(seed,branch+350),lineWidth:1.4+reactionUnit(seed,branch+450)*2.2,segments:[]},
|
||
count=3+Math.floor(reactionUnit(seed,branch+80)*3);
|
||
for(let segment=0;segment<count;segment++){angle+=(reactionUnit(seed,branch*11+segment+120)-.5)*.55;const length=24+reactionUnit(seed,branch*13+segment+240)*35;item.segments.push({dx:Math.cos(angle)*length,dy:Math.sin(angle)*length*.68})}
|
||
branches.push(item);
|
||
}
|
||
return branches;
|
||
}
|
||
function preparedLaserRays(seed){
|
||
const rays=[];
|
||
for(let index=0;index<30;index++){
|
||
const originAngle=reactionUnit(seed,index)*Math.PI*2,targetAngle=originAngle+(.55+reactionUnit(seed,index+100)*1.9)*(index%2?1:-1);
|
||
const length=170+reactionUnit(seed,index+150)*260,color=REACTION_LASER_COLORS[index%REACTION_LASER_COLORS.length],strong=index%5===0;
|
||
rays.push({originCos:Math.cos(originAngle),originSin:Math.sin(originAngle),targetCos:Math.cos(targetAngle),targetSin:Math.sin(targetAngle),originRadius:18+reactionUnit(seed,index+50)*135,length,speed:index%2?.7:-.45,color,strong});
|
||
}
|
||
return rays;
|
||
}
|
||
function preparedOrbitModel(seed){
|
||
const stars=[];
|
||
for(let index=0;index<32;index++){const angle=reactionUnit(seed,index)*Math.PI*2;stars.push({cos:Math.cos(angle),sin:Math.sin(angle),radius:110+reactionUnit(seed,index+90)*155,size:1+reactionUnit(seed,index+180)*2.2})}
|
||
const orbiters=[];
|
||
for(let index=0;index<15;index++){const angle=index*Math.PI*2/15;orbiters.push({angle,cos:Math.cos(angle),sin:Math.sin(angle),radius:125+(index%3)*20,size:24+(index%4)*3})}
|
||
return{stars,orbiters};
|
||
}
|
||
function preparedFireworkBloom(seed){
|
||
const rings=[[12,72,28],[18,118,24],[24,168,21]].map(([count,maxRadius,size],ring)=>{
|
||
const particles=[];for(let index=0;index<count;index++){const angle=index*Math.PI*2/count+(reactionUnit(seed,ring*40+index)-.5)*.08;particles.push({angle,cos:Math.cos(angle),sin:Math.sin(angle),index})}
|
||
return{count,maxRadius,size,particles};
|
||
});
|
||
return{rings,burst:preparedReactionBurst(seed,28)};
|
||
}
|
||
function prepareReactionModel(reaction){
|
||
const started=perfStart(),seed=reactionSeed(reaction.id),style=reaction.style||'classic',model={seed,style,duration:reaction.expiresAt-reaction.createdAt};
|
||
if(style==='giant')model.cracks=preparedReactionCracks(seed);
|
||
else if(style==='laser')model.rays=preparedLaserRays(seed);
|
||
else if(style==='orbit')Object.assign(model,preparedOrbitModel(seed));
|
||
else if(style==='firework')model.blooms=[preparedFireworkBloom(seed),preparedFireworkBloom(seed+17),preparedFireworkBloom(seed+31)];
|
||
else if(style==='comet'){
|
||
model.direction=reactionUnit(seed,2)>.5?1:-1;model.startYJitter=(reactionUnit(seed,3)-.5)*120;model.burst=preparedReactionBurst(seed+99,42);model.trail=[];
|
||
for(let index=18;index>=1;index--)model.trail.push({index,delay:index*.032,size:24+(18-index)*.55,alphaFactor:(1-index/20)*.62,scale:.55+index*.012});
|
||
model.explosion=[];for(let index=0;index<28;index++){const angle=reactionUnit(seed,index+500)*Math.PI*2;model.explosion.push({angle,cos:Math.cos(angle),sin:Math.sin(angle),radiusFactor:36+reactionUnit(seed,index+560)*235,size:19+(index%5)*3})}
|
||
}
|
||
perfCount('reactionPreparedModels');perfEnd('reactionPrepare',started);return model;
|
||
}
|
||
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):'',style=REACTION_STYLE_IDS.has(raw?.style)?raw.style:'classic';
|
||
if(!id||!REACTION_EMOJIS.includes(emoji)||!Number.isFinite(x)||!Number.isFinite(y)||!Number.isFinite(createdAt)||!Number.isFinite(expiresAt)||expiresAt<=trustedNow())return null;
|
||
const reaction={id,emoji,style,x,y,createdAt,expiresAt,playerId:raw.playerId||null,playerName:String(raw.playerName||'').slice(0,24)};reaction.prepared=prepareReactionModel(reaction);return reaction;
|
||
}
|
||
function applyRealtimeReaction(raw){
|
||
const started=perfStart(),reaction=normalizeRealtimeReaction(raw);if(!reaction)return false;
|
||
realtimeReactions.set(reaction.id,reaction);reactionDirty=true;perfGauge('activeReactions',realtimeReactions.size);scheduleReactionRender();perfEnd('reactionPublish',started);return true;
|
||
}
|
||
function cancelReactionRenderScheduler(resetDeadline=false){
|
||
if(reactionDelayTimer)clearTimeout(reactionDelayTimer);if(reactionWatchdogTimer)clearTimeout(reactionWatchdogTimer);if(reactionFrame)cancelAnimationFrame(reactionFrame);if(reactionCalibrationFrame)cancelAnimationFrame(reactionCalibrationFrame);reactionDelayTimer=0;reactionWatchdogTimer=0;reactionFrame=0;reactionCalibrationFrame=0;if(resetDeadline){reactionNextDrawAt=0;reactionLastDraw=0;reactionLastMetricFrame=0}
|
||
}
|
||
function commitReactionRender(timestamp,source='raf'){
|
||
if(source==='raf'&&reactionNextDrawAt&×tamp+1<reactionNextDrawAt){scheduleReactionRender();return false}
|
||
const previousDraw=reactionLastDraw;if(reactionLastMetricFrame)perfObserve('reactionFrameGap',Math.max(0,timestamp-reactionLastMetricFrame));reactionLastMetricFrame=timestamp;reactionLastDraw=timestamp;
|
||
if(!previousDraw){
|
||
reactionNextDrawAt=timestamp+REACTION_FRAME_INTERVAL;
|
||
if(!reactionVsyncCalibrated)reactionCalibrationFrame=requestAnimationFrame(calibrationTimestamp=>{reactionCalibrationFrame=0;reactionVsyncInterval=Math.max(4,Math.min(20,calibrationTimestamp-timestamp));reactionVsyncCalibrated=true;perfCount('reactionCalibrationRafCallbacks');scheduleReactionRender()});
|
||
}else{let advances=0;do{reactionNextDrawAt+=REACTION_FRAME_INTERVAL;advances++}while(reactionNextDrawAt<=timestamp+1);if(advances>1)perfCount('reactionSkippedDeadlines',advances-1)}
|
||
markVisualFrame(timestamp);drawReactionLayer(timestamp);return true;
|
||
}
|
||
function scheduleReactionRender(markDirty=false){
|
||
if(markDirty)reactionDirty=true;
|
||
if(!reactionCanvas||document.visibilityState==='hidden'||reactionFrame||reactionDelayTimer||reactionCalibrationFrame||(!reactionDirty&&!realtimeReactions.size))return;
|
||
const now=perfNow();if(!reactionNextDrawAt)reactionNextDrawAt=now;const wait=reactionNextDrawAt-now;
|
||
const timerDelay=wait-reactionVsyncInterval+1.5;
|
||
if(timerDelay>2){
|
||
reactionDelayTimer=setTimeout(()=>{reactionDelayTimer=0;perfCount('reactionDeadlineTimerCallbacks');scheduleReactionRender()},timerDelay);return;
|
||
}
|
||
reactionFrame=requestAnimationFrame(timestamp=>{reactionFrame=0;if(reactionWatchdogTimer)clearTimeout(reactionWatchdogTimer);reactionWatchdogTimer=0;perfCount('reactionDrawRafCallbacks');if(document.visibilityState==='hidden')return;commitReactionRender(timestamp,'raf')});
|
||
if(reactionLastDraw){
|
||
const watchdogDelay=Math.max(2,reactionNextDrawAt-perfNow()+3);
|
||
reactionWatchdogTimer=setTimeout(()=>{reactionWatchdogTimer=0;if(!reactionFrame)return;cancelAnimationFrame(reactionFrame);reactionFrame=0;perfCount('reactionWatchdogCallbacks');if(document.visibilityState!=='hidden')commitReactionRender(perfNow(),'watchdog')},watchdogDelay);
|
||
}
|
||
}
|
||
function drawReactionEmoji(context,emoji,x,y,size,alpha=1,rotation=0,scale=1){
|
||
perfCount('reactionEmojiDraws');context.save();context.globalAlpha*=Math.max(0,alpha);context.translate(x,y);context.rotate(rotation);context.scale(scale,scale);
|
||
context.shadowColor='rgba(0,0,0,.7)';context.shadowBlur=Math.max(4,size*.14);const sprite=rotation===0&&scale===1?reactionGlyphSprite(context,emoji,size):null;
|
||
if(sprite)context.drawImage(sprite.canvas,-sprite.width/2,-sprite.height/2);
|
||
else{context.font=`${size}px ${REACTION_FONT}`;context.textAlign='center';context.textBaseline='middle';context.fillText(emoji,0,0)}
|
||
context.restore();
|
||
}
|
||
function beginReactionEmojiBatch(context){context.save();context.shadowColor='rgba(0,0,0,.7)';context.textAlign='center';context.textBaseline='middle';return{transform:context.getTransform(),alpha:context.globalAlpha,fontSize:0}}
|
||
function drawReactionEmojiBatched(context,batch,emoji,sprite,x,y,size,alpha=1,rotation=0,scale=1){
|
||
perfCount('reactionEmojiDraws');context.setTransform(batch.transform);context.globalAlpha=batch.alpha*Math.max(0,alpha);context.translate(x,y);context.rotate(rotation);context.scale(scale,scale);context.shadowBlur=Math.max(4,size*.14);
|
||
if(sprite&&rotation===0&&scale===1)context.drawImage(sprite.canvas,-sprite.width/2,-sprite.height/2);
|
||
else{if(batch.fontSize!==size){context.font=`${size}px ${REACTION_FONT}`;batch.fontSize=size}context.fillText(emoji,0,0)}
|
||
}
|
||
function endReactionEmojiBatch(context){context.restore()}
|
||
function drawReactionFlash(context,radius,alpha=.8){perfCount('reactionGradients');const gradient=context.createRadialGradient(0,0,0,0,0,radius);gradient.addColorStop(0,`rgba(255,255,255,${alpha})`);gradient.addColorStop(.18,`rgba(255,240,170,${alpha*.75})`);gradient.addColorStop(.55,`rgba(255,90,210,${alpha*.28})`);gradient.addColorStop(1,'rgba(70,190,255,0)');context.save();context.globalCompositeOperation='screen';context.fillStyle=gradient;context.beginPath();context.arc(0,0,radius,0,Math.PI*2);context.fill();context.restore();perfCount('reactionPaths')}
|
||
function drawReactionBurst(context,rays,progress,maxRadius=120,alpha=1){const eased=1-Math.pow(1-Math.max(0,Math.min(1,progress)),3);context.save();context.globalCompositeOperation='screen';context.lineCap='round';for(const ray of rays){const radius=maxRadius*ray.radiusFactor*eased,inner=Math.max(4,radius-ray.innerOffset);context.strokeStyle=`hsla(${ray.hue},100%,72%,${alpha*(1-progress)})`;context.lineWidth=ray.lineWidth;context.beginPath();context.moveTo(ray.cos*inner,ray.sin*inner);context.lineTo(ray.cos*radius,ray.sin*radius);context.stroke()}context.restore();perfCount('reactionPaths',rays.length)}
|
||
function drawCrackedGround(context,branches,progress){const reveal=Math.min(1,progress*4),alpha=(1-progress*.55)*reveal;context.save();context.globalCompositeOperation='screen';context.lineCap='round';context.shadowColor='rgba(140,220,255,.75)';context.shadowBlur=7;for(const branch of branches){let x=branch.x,y=branch.y;context.beginPath();context.moveTo(x,y);for(const segment of branch.segments){x+=segment.dx*reveal;y+=segment.dy*reveal;context.lineTo(x,y)}context.strokeStyle=`rgba(205,245,255,${alpha*branch.alpha})`;context.lineWidth=branch.lineWidth;context.stroke()}context.restore();perfCount('reactionPaths',branches.length)}
|
||
function drawGiantReaction(context,emoji,life,model){const impact=Math.min(1,life/.2),impactEase=1-Math.pow(1-impact,4),after=Math.max(0,(life-.16)/.84),shake=(1-after)*Math.sin(after*96)*13;context.save();context.translate(Math.sin(after*137)*shake,Math.cos(after*113)*shake*.62);drawCrackedGround(context,model.cracks,after);if(life>.12)drawReactionFlash(context,190*(1-after)+70,Math.max(0,.72-after*.8));for(let ring=0;ring<3;ring++){const p=Math.max(0,Math.min(1,(after-ring*.07)*1.45));context.save();context.globalCompositeOperation='screen';context.strokeStyle=`rgba(${ring===1?'255,95,220':'115,225,255'},${(1-p)*.62})`;context.lineWidth=8-2*ring;context.beginPath();context.ellipse(0,24,30+230*p,16+105*p,0,0,Math.PI*2);context.stroke();context.restore()}perfCount('reactionPaths',3);const squash=1+Math.sin(Math.min(1,impact)*Math.PI)*.2;drawReactionEmoji(context,emoji,0,38-78*impactEase,142,1,0,(.18+.92*impactEase)*squash);context.restore()}
|
||
function drawLaserReaction(context,emoji,life,model){const attack=Math.min(1,life/.1),fade=Math.max(0,1-Math.max(0,life-.78)/.22),spin=life*Math.PI*7,positiveAngle=spin*.7,negativeAngle=spin*-.45,positiveCos=Math.cos(positiveAngle),positiveSin=Math.sin(positiveAngle),negativeCos=Math.cos(negativeAngle),negativeSin=Math.sin(negativeAngle);drawReactionFlash(context,90+25*Math.sin(life*22),.34*fade);context.save();context.globalCompositeOperation='screen';context.lineCap='round';for(const ray of model.rays){const ct=ray.speed>0?positiveCos:negativeCos,st=ray.speed>0?positiveSin:negativeSin,originCos=ray.originCos*ct-ray.originSin*st,originSin=ray.originSin*ct+ray.originCos*st,targetCos=ray.targetCos*ct-ray.targetSin*st,targetSin=ray.targetSin*ct+ray.targetCos*st,x1=originCos*ray.originRadius,y1=originSin*ray.originRadius*.72,x2=x1+targetCos*ray.length,y2=y1+targetSin*ray.length,gradient=context.createLinearGradient(x1,y1,x2,y2);perfCount('reactionGradients');gradient.addColorStop(0,'rgba(255,255,255,.92)');gradient.addColorStop(.12,ray.color);gradient.addColorStop(1,'rgba(80,220,255,0)');context.strokeStyle=gradient;context.lineWidth=(ray.strong?5:2.2)*attack*fade;context.shadowColor=ray.color;context.shadowBlur=ray.strong?16:8;context.beginPath();context.moveTo(x1,y1);context.lineTo(x2,y2);context.stroke()}for(let index=0;index<7;index++){const angle=spin*.6+index*Math.PI*2/7,radius=62+24*Math.sin(life*18+index);context.fillStyle=REACTION_LASER_COLORS[index%REACTION_LASER_COLORS.length];context.beginPath();context.arc(Math.cos(angle)*radius,Math.sin(angle)*radius*.68,3+3*Math.sin(life*24+index)**2,0,Math.PI*2);context.fill()}perfCount('reactionPaths',37);context.restore();drawReactionEmoji(context,emoji,0,0,60,fade,spin*3.8,.65+.4*attack)}
|
||
function drawOrbitReaction(context,emoji,life,model){
|
||
const enter=Math.min(1,life/.16),fade=Math.max(0,1-Math.max(0,life-.84)/.16),scale=.35+.65*(1-Math.pow(1-enter,4)),spin=life*Math.PI*3.6,ct=Math.cos(spin),st=Math.sin(spin);context.save();context.scale(scale,scale);perfCount('reactionGradients');
|
||
const nebula=context.createRadialGradient(0,0,18,0,0,250);nebula.addColorStop(0,`rgba(95,175,255,${.24*fade})`);nebula.addColorStop(.48,`rgba(115,70,220,${.14*fade})`);nebula.addColorStop(1,'rgba(20,10,80,0)');context.fillStyle=nebula;const nebulaPath=reactionOrbitNebulaPath();if(nebulaPath)context.fill(nebulaPath);else{context.beginPath();context.arc(0,0,250,0,Math.PI*2);context.fill()}
|
||
context.save();context.rotate(-.34);context.globalCompositeOperation='screen';for(let ring=0;ring<5;ring++){context.strokeStyle=ring%2?`rgba(255,210,120,${(.5-ring*.05)*fade})`:`rgba(125,220,255,${(.68-ring*.07)*fade})`;context.lineWidth=ring===2?8:2.5+ring*.7;const ringPath=reactionOrbitRingPath(ring);if(ringPath)context.stroke(ringPath);else{context.beginPath();context.ellipse(0,0,118+ring*15,37+ring*8,0,0,Math.PI*2);context.stroke()}}
|
||
const batch=beginReactionEmojiBatch(context);for(const orbiter of model.orbiters){const cos=orbiter.cos*ct-orbiter.sin*st,sin=orbiter.sin*ct+orbiter.cos*st;drawReactionEmojiBatched(context,batch,emoji,null,cos*orbiter.radius,sin*orbiter.radius*.34,orbiter.size,.72*fade,-.15,.78)}endReactionEmojiBatch(context);context.restore();
|
||
for(let index=0;index<model.stars.length;index++){const star=model.stars[index],twinkle=.25+.75*Math.sin(life*26+index)**2;context.fillStyle=`rgba(220,245,255,${twinkle*fade})`;context.beginPath();context.arc(star.cos*star.radius,star.sin*star.radius,star.size,0,Math.PI*2);context.fill()}perfCount('reactionPaths',38);drawReactionEmoji(context,emoji,0,0,112,fade,Math.sin(life*4)*.025,1);context.restore();
|
||
}
|
||
function drawFireworkBloom(context,emoji,model,progress,x=0,y=0,scale=1){if(progress<=0||progress>=1)return;const open=1-Math.pow(1-Math.min(1,progress*1.18),3),fade=Math.max(0,1-Math.max(0,progress-.72)/.28);context.save();context.translate(x,y);context.scale(scale,scale);drawReactionFlash(context,48+85*open,.55*fade);const batch=beginReactionEmojiBatch(context);for(let ringIndex=0;ringIndex<model.rings.length;ringIndex++){const ring=model.rings[ringIndex],ringProgress=Math.max(0,Math.min(1,(progress-ringIndex*.055)*1.18)),radius=ring.maxRadius*(1-Math.pow(1-ringProgress,3));for(const particle of ring.particles){const alpha=fade*(.76+.24*Math.sin(particle.index+progress*18)**2),particleScale=.7+.3*ringProgress;drawReactionEmojiBatched(context,batch,emoji,null,particle.cos*radius,particle.sin*radius,ring.size,alpha,particle.angle,particleScale)}}endReactionEmojiBatch(context);drawReactionBurst(context,model.burst,progress,195,.85*fade);context.restore()}
|
||
function drawFireworkReaction(context,emoji,life,model){
|
||
const launch=Math.min(1,life/.3),launchEase=1-Math.pow(1-launch,3);if(life<.34){context.save();context.globalCompositeOperation='screen';const y=190-190*launchEase;context.translate(0,y);for(let index=0;index<12;index++){const p=index/12;context.fillStyle=`rgba(255,${150+Math.round(p*100)},90,${(1-p)*.75})`;const launchPath=reactionFireworkLaunchPath(index);if(launchPath)context.fill(launchPath);else{context.beginPath();context.arc(Math.sin(index*2.1)*5,index*12,2.5+(1-p)*2.5,0,Math.PI*2);context.fill()}}perfCount('reactionPaths',12);context.restore();drawReactionEmoji(context,emoji,0,y,38,1,-.08,.8+.25*launchEase)}
|
||
const bloom=Math.max(0,(life-.24)/.76);drawFireworkBloom(context,emoji,model.blooms[0],bloom,0,0,1);drawFireworkBloom(context,emoji,model.blooms[1],Math.max(0,bloom-.14)/.86,-165,42,.58);drawFireworkBloom(context,emoji,model.blooms[2],Math.max(0,bloom-.24)/.76,170,-24,.48);
|
||
}
|
||
function drawCometReaction(context,emoji,life,model){const travel=Math.min(1,life/.7),travelEase=1-Math.pow(1-travel,4),explode=Math.max(0,(life-.68)/.32),width=context.canvas?.clientWidth||context.canvas?.width||900,height=context.canvas?.clientHeight||context.canvas?.height||600,startX=-model.direction*Math.max(420,width*.62),startY=-Math.max(290,height*.48)+model.startYJitter,x=startX*(1-travelEase),y=startY*(1-travelEase),alpha=Math.max(0,1-life);context.save();if(explode>0){const shake=(1-explode)*22;context.translate(Math.sin(explode*165)*shake,Math.cos(explode*131)*shake)}if(travel<1){const angle=Math.atan2(-startY,-startX);context.save();context.globalCompositeOperation='screen';const batch=beginReactionEmojiBatch(context);for(const trail of model.trail){const p=Math.max(0,Math.min(1,(travel-trail.delay)/(1-trail.delay))),e=1-Math.pow(1-p,4);drawReactionEmojiBatched(context,batch,emoji,null,startX*(1-e),startY*(1-e),trail.size,alpha*trail.alphaFactor,angle,trail.scale)}endReactionEmojiBatch(context);perfCount('reactionGradients');const gradient=context.createLinearGradient(x,y,startX*(1-Math.max(0,travelEase-.32)),startY*(1-Math.max(0,travelEase-.32)));gradient.addColorStop(0,`rgba(255,255,255,${.95*alpha})`);gradient.addColorStop(.18,`rgba(255,215,80,${.8*alpha})`);gradient.addColorStop(.48,`rgba(255,75,210,${.45*alpha})`);gradient.addColorStop(1,'rgba(70,190,255,0)');context.strokeStyle=gradient;context.lineWidth=18*(1-travel)+5;context.lineCap='round';context.beginPath();context.moveTo(x,y);context.lineTo(x-Math.cos(angle)*(120+220*(1-travel)),y-Math.sin(angle)*(120+220*(1-travel)));context.stroke();perfCount('reactionPaths');context.restore();drawReactionEmoji(context,emoji,x,y,52,alpha,angle,.72+.48*travelEase)}if(explode>0){drawReactionFlash(context,80+210*(1-Math.pow(1-explode,3)),.95*(1-explode));for(let ring=0;ring<3;ring++){const p=Math.max(0,Math.min(1,(explode-ring*.07)*1.22));context.save();context.globalCompositeOperation='screen';context.strokeStyle=`rgba(${ring===1?'255,90,220':'120,230,255'},${(1-p)*.82})`;context.lineWidth=10-ring*2.4;context.beginPath();context.arc(0,0,24+250*p,0,Math.PI*2);context.stroke();context.restore()}perfCount('reactionPaths',3);drawReactionBurst(context,model.burst,explode,280,1);const eased=1-Math.pow(1-explode,3),batch=beginReactionEmojiBatch(context);for(const particle of model.explosion)drawReactionEmojiBatched(context,batch,emoji,null,particle.cos*particle.radiusFactor*eased,particle.sin*particle.radiusFactor*eased,particle.size,(1-explode)*.86,particle.angle,.62);endReactionEmojiBatch(context)}context.restore()}
|
||
function drawStyledReaction(context,reaction,life,fade){
|
||
const emoji=reaction.emoji,style=reaction.style||'classic',model=reaction.prepared||(reaction.prepared=prepareReactionModel(reaction)),started=perfStart();
|
||
context.save();context.globalAlpha=Math.max(0,fade);
|
||
if(style==='giant')drawGiantReaction(context,emoji,life,model);
|
||
else if(style==='laser')drawLaserReaction(context,emoji,life,model);
|
||
else if(style==='orbit')drawOrbitReaction(context,emoji,life,model);
|
||
else if(style==='firework')drawFireworkReaction(context,emoji,life,model);
|
||
else if(style==='comet')drawCometReaction(context,emoji,life,model);
|
||
else{const enter=Math.min(1,life/.16),out=Math.max(0,1-Math.max(0,life-.48)/.52),floatY=-18*(1-Math.pow(1-life,1.7));drawReactionEmoji(context,emoji,0,floatY,40,out,0,.72+.28*(1-Math.pow(1-enter,3)))}
|
||
context.restore();perfEnd(`reactionStyle.${style}`,started);
|
||
}
|
||
function drawReactionLayer(){
|
||
if(!reactionCanvas)return;const started=perfStart(),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}),compositeStarted=perfStart();context.setTransform(dpr,0,0,dpr,0,0);context.clearRect(0,0,width,height);perfCount('reactionCanvasPixelsCleared',width*height);reactionDirty=false;
|
||
const timestamp=trustedNow();let active=0,visible=0;
|
||
for(const[id,reaction]of realtimeReactions){
|
||
if(reaction.expiresAt<=timestamp){realtimeReactions.delete(id);perfCount('reactionModelsReleased');continue}
|
||
active++;const life=Math.max(0,Math.min(1,(timestamp-reaction.createdAt)/(reaction.expiresAt-reaction.createdAt))),fade=life>.94?(1-life)/.06:1,x=(reaction.x-renderOriginX)*UNIT*cam.scale+cam.x,y=(reaction.y-renderOriginY)*UNIT*cam.scale+cam.y;
|
||
if(x<-420||y<-420||x>width+420||y>height+420)continue;
|
||
visible++;context.save();context.translate(x,y);drawStyledReaction(context,reaction,life,fade);context.restore();
|
||
}
|
||
perfEnd('reactionComposite',compositeStarted);perfGauge('activeReactions',active);perfGauge('visibleReactions',visible);perfGauge('peakVisibleReactions',Math.max(perfGauges.peakVisibleReactions||0,visible));perfGauge('preparedReactionModels',realtimeReactions.size);perfGauge('reactionOverload',active>8?active:0);perfCount('reactionFrames');perfEnd('reactionFrame',started);
|
||
if(active)scheduleReactionRender();else reactionNextDrawAt=0;
|
||
}
|
||
function renderReactionSample({style='classic',emoji='😀',life=.5,width=900,height=700,glyphCache=true,pathCache=true}={}){
|
||
const normalizedStyle=REACTION_STYLE_IDS.has(style)?style:'classic',normalizedEmoji=REACTION_EMOJIS.includes(emoji)?emoji:REACTION_EMOJIS[0],normalizedLife=Math.max(0,Math.min(1,Number(life)||0)),canvas=document.createElement('canvas');canvas.width=Math.max(1,Math.round(width));canvas.height=Math.max(1,Math.round(height));
|
||
const context=canvas.getContext('2d',{alpha:true}),reaction={id:`sample-${normalizedStyle}`,emoji:normalizedEmoji,style:normalizedStyle,x:0,y:0,createdAt:0,expiresAt:reactionDurationForStyle(normalizedStyle)};reaction.prepared=prepareReactionModel(reaction);context.translate(canvas.width/2,canvas.height/2);
|
||
if(!glyphCache)reactionGlyphCacheBypass++;if(!pathCache)reactionStaticPathCacheBypass++;try{drawStyledReaction(context,reaction,normalizedLife,normalizedLife>.94?(1-normalizedLife)/.06:1)}finally{if(!glyphCache)reactionGlyphCacheBypass--;if(!pathCache)reactionStaticPathCacheBypass--}
|
||
return{style:normalizedStyle,life:normalizedLife,width:canvas.width,height:canvas.height,dataUrl:canvas.toDataURL('image/png')};
|
||
}
|
||
let activeLocalSpecialReactionUntil=0;
|
||
function specialReactionInputLocked(){return data?.reactionStyle!=='classic'&&Date.now()<activeLocalSpecialReactionUntil}
|
||
function publishReactionAt(emoji,x,y){if(!REACTION_EMOJIS.includes(emoji)||!Number.isFinite(x)||!Number.isFinite(y))return false;const timestamp=Date.now(),style=REACTION_STYLE_IDS.has(data.reactionStyle)?data.reactionStyle:'classic';if(style!=='classic'&×tamp<activeLocalSpecialReactionUntil){toast('特殊エフェクトの再生中です。');return false}data.lastReaction=emoji;markGlobalDirty(false);const expiresAt=timestamp+reactionDurationForStyle(style),reaction={id:`local-${sessionId}-${++reactionSequence}`,emoji,style,x,y,createdAt:timestamp,expiresAt,playerId:currentPlayerId(),playerName:currentPlayerName()};if(style!=='classic')activeLocalSpecialReactionUntil=expiresAt;applyRealtimeReaction(reaction);sendOrQueueRealtimeReaction(reaction);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||!gestureCoordinator.claim(gesture.pointerId,'reaction'))return;gesture.menuOpen=true;interactionState.set('reaction',gesture.pointerId);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(specialReactionInputLocked())return;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;gestureCoordinator.release(event.pointerId,'reaction');interactionState.clear('reaction');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){gestureCoordinator.release(pointerId,'reaction','cancelled');try{if(viewport.hasPointerCapture?.(pointerId))viewport.releasePointerCapture(pointerId)}catch(_){}}interactionState.clear('reaction');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 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));setBoardHudVisibility(board,hudVisible);board.label?.classList.toggle('claimed-own',Boolean(claim&&own&&!solved));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]:rendered.keys();for(const id of ids){const board=rendered.get(id);if(board)applyClaimPresentationToBoard(board)}
|
||
}
|
||
function applyClaim(raw){if(!raw?.boardId)return;if(metaState(raw.boardId)?.solved===true){removeClaim(raw.boardId,'cleared');return}boardClaims.set(raw.boardId,{...raw,expiresAt:Number(raw.expiresAt)||trustedNow()});if(raw.playerId===currentPlayerId())realtimeOwnClaimBoardId=raw.boardId;refreshClaimPresentation(raw.boardId)}
|
||
function preserveActiveDrawingClaim(boardId,claim,reason){
|
||
const board=rendered.get(boardId),pointerId=board?.drawing?.pointerId;if(reason==='cleared'||claim?.playerId!==currentPlayerId()||pointerId==null||!realtimeHeldPointers.has(pointerId))return false;
|
||
claim.expiresAt=Math.max(Number(claim.expiresAt)||0,trustedNow()+30000);boardClaims.set(boardId,claim);realtimeOwnClaimBoardId=boardId;refreshClaimPresentation(boardId);
|
||
void requestBoardClaim(boardId,{force:true}).then(ok=>{if(!ok&&board?.drawing?.pointerId===pointerId)console.warn(`LinkField: board claim recovery is pending for ${boardId}`)});return true;
|
||
}
|
||
function removeClaim(boardId,reason='released'){const claim=boardClaims.get(boardId);if(preserveActiveDrawingClaim(boardId,claim,reason))return false;boardClaims.delete(boardId);if(realtimeOwnClaimBoardId===boardId)realtimeOwnClaimBoardId=null;refreshClaimPresentation(boardId);return true}
|
||
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 applyRealtimeWorldDelta(message){
|
||
const revision=Math.max(0,Number(message?.revision)||0),page=message?.page;if(!revision||revision<=(data.cloudRevision||0)||!page||!isPlainObject(page.metas)||!isPlainObject(page.states))return false;
|
||
const external={...(page.global||{}),worldEpoch:data.worldEpoch,metas:{},states:{}};
|
||
for(const[id,raw]of Object.entries(page.metas)){const meta=normalizeMeta(id,raw);if(meta)external.metas[id]=meta}
|
||
for(const[id,raw]of Object.entries(page.states)){const board=rendered.get(id),locallyOwned=boardClaimOwnedByMe(id)||board?.drawing?.pointerId!=null||board?.drawing?.keyboardActive;if(!locallyOwned)external.states[id]=normalizeState(raw)}
|
||
cloudApplyingRemote=true;try{mergeSnapshotIntoData(external,{finalize:false,authoritativeWorld:false});for(const id of Object.keys(external.metas))dirtyMetaIds.add(id);for(const id of Object.keys(external.states))dirtyStateIds.add(id);resolveMergedOverlaps();data.cloudRevision=revision;remoteWorldRevision=Math.max(remoteWorldRevision,revision);statsDirty=true;markGlobalDirty(false)}finally{cloudApplyingRemote=false}
|
||
void refreshWorldView({rebuild:true,syncConnections:true,persist:false});void persistNow({skipCloud:true}).then(ok=>{if(!ok)scheduleCloudCheckpointRetry()});return true;
|
||
}
|
||
function settleRealtimeClaimResult(message){const pending=realtimeClaimRequests.get(message.requestId);if(!pending)return;if(message.claim)applyClaim(message.claim);pending.resolve(message)}
|
||
function handleRealtimeMessage(message){
|
||
if(!message||typeof message!=='object')return;
|
||
if(message.type==='ready'){realtimeReady=true;realtimePresenceId=message.presenceId;if(realtimeTransport==='websocket')realtimeWebSocketFailures=0;setCloudStatus();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});flushPendingRealtimeReactions();scheduleRealtimeHeartbeat();if(realtimeOwnClaimBoardId&&rendered.get(realtimeOwnClaimBoardId)?.drawing?.pointerId!=null)void requestBoardClaim(realtimeOwnClaimBoardId,{force:true});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'){removeClaim(message.boardId,message.reason);if(message.reason!=='moved')void pullCloudWorld();return}
|
||
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();}
|
||
if(message.type==='world-revision'){if(!applyRealtimeWorldDelta(message))scheduleRemoteWorldPull(Number(message.revision)||0);return}
|
||
}
|
||
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 resetRealtimeConnectionState({clearRemote=true}={}){clearTimeout(realtimeHeartbeatTimer);realtimeHeartbeatTimer=0;clearTimeout(realtimePollTimer);realtimePollTimer=0;realtimeReady=false;realtimePresenceId=null;realtimeSocket=null;realtimeTransport='none';setCloudStatus();if(clearRemote){remotePlayers.clear();realtimeReactions.clear();reactionDirty=true;rejectRealtimeClaims();schedulePresenceRender(true);scheduleReactionRender(true);scheduleMinimap(true)}}
|
||
function connectRealtimeHttp(){
|
||
if(realtimeHttpConnecting||realtimeTransport==='http-poll'&&realtimePresenceId)return true;realtimeHttpConnecting=true;realtimeTransport='http-poll';realtimeReady=false;realtimePollSequence=0;
|
||
void enqueueRealtimeHttp(async()=>{try{const result=await fetchJson('/api/realtime/connect',{method:'POST',headers:cloudAuthHeaders(),body:'{}'},5000);applyRealtimeHttpEnvelope(result);scheduleRealtimeHttpPoll(100)}catch(error){resetRealtimeConnectionState();scheduleRealtimeReconnect(1500)}finally{realtimeHttpConnecting=false}});return true;
|
||
}
|
||
function connectRealtime(){
|
||
clearTimeout(realtimeReconnectTimer);realtimeReconnectTimer=0;if(!cloudApiEnabled||!cloudAvailable||!data.cloudProfile)return false;
|
||
if(cloudApiUsesPhpBridge()||runtimeConfig.realtimeTransport==='http-poll'||typeof WebSocket==='undefined'||realtimeWebSocketFailures>=2)return connectRealtimeHttp();
|
||
if(realtimeSocket&&[WebSocket.OPEN,WebSocket.CONNECTING].includes(realtimeSocket.readyState))return true;
|
||
const socket=new WebSocket(realtimeWebSocketUrl());realtimeSocket=socket;realtimeTransport='websocket';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;realtimeWebSocketFailures++;resetRealtimeConnectionState();scheduleRealtimeReconnect(realtimeWebSocketFailures>=2?100:1500)};return true;
|
||
}
|
||
const directBoardClaimRequests=new Map();
|
||
function requestBoardClaimThroughRealtime(boardId,timeout=2200){
|
||
if(!realtimeReady)return Promise.resolve(null);
|
||
const requestId=`claim-${sessionId}-${++realtimeRequestSequence}`;
|
||
return new Promise(resolve=>{
|
||
const finish=result=>{const pending=realtimeClaimRequests.get(requestId);if(!pending)return;if(pending.timer)clearTimeout(pending.timer);realtimeClaimRequests.delete(requestId);resolve(result)};
|
||
const timer=setTimeout(()=>finish(null),timeout);realtimeClaimRequests.set(requestId,{timer,resolve:message=>finish(message)});
|
||
if(!realtimeSend({type:'claim',requestId,boardId}))finish(null);
|
||
});
|
||
}
|
||
async function requestBoardClaim(boardId,{allowLocalSolved=false,force=false}={}){
|
||
if(!allowLocalSolved&&metaState(boardId)?.solved||!cloudAvailable||!data.cloudProfile)return false;
|
||
const existing=currentBoardClaim(boardId),now=trustedNow();
|
||
if(existing?.playerId!==currentPlayerId()&&existing?.expiresAt>now)return false;
|
||
if(!force&&existing?.playerId===currentPlayerId()&&existing.expiresAt-now>30000)return true;
|
||
if(directBoardClaimRequests.has(boardId))return directBoardClaimRequests.get(boardId);
|
||
const request=(async()=>{
|
||
try{
|
||
try{
|
||
const result=await fetchJson('/api/realtime/claim',{method:'POST',headers:cloudAuthHeaders(),body:JSON.stringify({boardId,presenceId:realtimePresenceId})},2500);
|
||
if(result?.claim)applyClaim(result.claim);
|
||
if(result?.ok===true)return true;
|
||
if(result?.ok===false)return false;
|
||
}catch(error){console.warn('LinkField: direct board claim unavailable; using realtime transport',error)}
|
||
const realtimeResult=await requestBoardClaimThroughRealtime(boardId,3000);
|
||
if(realtimeResult?.claim)applyClaim(realtimeResult.claim);
|
||
return realtimeResult?.ok===true;
|
||
}catch(error){console.warn('LinkField: board claim failed',error);return false}
|
||
finally{directBoardClaimRequests.delete(boardId)}
|
||
})();
|
||
directBoardClaimRequests.set(boardId,request);return request;
|
||
}
|
||
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}
|
||
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});
|
||
|
||
const SHARED_PERSONAL_FIELDS=Object.freeze(['cloudProfile','playerName','playerPurchases','playerEarnedScore','starterLineColor','lineColorStyle','lineEffectStyle','reactionStyle','lastReaction','timeAttack','timeAttackRev','timeAttackCooldowns','lastTimeAttack','timeAttackSuggestionsDisabled','cursorStyle','scoreLensEnabled']);
|
||
async function activateSingleSharedClientCache(){
|
||
if(!idbAvailable)return true;
|
||
const epoch=data?.worldEpoch;if(!validWorldEpoch(epoch))throw new Error('The shared client cache epoch is invalid.');
|
||
const db=await openWorldDb(),updatedAt=trustedNow(),tx=db.transaction(['control','worlds'],'readwrite'),done=transactionDone(tx),controlStore=tx.objectStore('control'),worldsStore=tx.objectStore('worlds'),
|
||
current=await requestValue(controlStore.get('active'));
|
||
if(current?.activeEpoch&¤t.activeEpoch!==epoch){const previous=await requestValue(worldsStore.get(current.activeEpoch));if(previous){previous.status='garbage';worldsStore.put(previous)}}
|
||
const control={key:'active',activeFormat:FIELD_STORAGE_FORMAT,activeEpoch:epoch,activationId:`shared:${sessionId}:${updatedAt}`,activationVerified:true,switchedAt:updatedAt};
|
||
worldsStore.put({epoch,status:'active',schema:SAVE_SCHEMA,worldGeneration:WORLD_GENERATION,global:globalForStorage(data,updatedAt),boardCount:0,solvedCount:0,score:0,bounds:{minX:0,minY:0,maxX:1,maxY:1},approximateBytes:0,createdAt:updatedAt,activatedAt:updatedAt,source:{kind:'single-shared-world'}});
|
||
controlStore.put(control);await done;startupWorldControl=control;rememberWorldEpoch(epoch);return true;
|
||
}
|
||
function resetClientToSingleSharedWorld(){
|
||
const previous=data||defaultData(),fresh=defaultData();
|
||
for(const field of SHARED_PERSONAL_FIELDS)if(Object.prototype.hasOwnProperty.call(previous,field))fresh[field]=deepClone(previous[field]);
|
||
fresh.worldEpoch=createWorldEpoch();fresh.cloudRevision=0;fresh.worldFeedRevision=0;fresh.cloudPending=normalizeCloudPending(null);fresh.cameraAnchor=null;fresh.selectedBoardId=null;data=fresh;
|
||
for(const collection of[dirtyMetaIds,dirtyStateIds,deletedBoardIds,cloudJournalMetaIds,cloudJournalStateIds,cloudJournalDeletedIds,cloudOutboxDeleteKeys])collection.clear();
|
||
deletedBoardRevisions.clear();deletedBoardAuthors.clear();stateStatSignatures.clear();stateEconomySignatures.clear();boardIndexSummaries.clear();hydratedBoardLru.clear();adjacencyCache.clear();rendered.clear();boardClaims.clear();remotePlayers.clear();realtimeReactions.clear();
|
||
occupancy=new Map();closedVoidKeys=new Set();activeBoard=null;hudBoardId=null;hydratedBoardBytes=0;cloudJournalGlobalChanged=false;cloudJournalChangeSeq=0;globalDirty=false;globalChangeSeq=0;cloudPushPending=emptyCloudPending();lastCloudWorldGlobalSignature='';fieldIndexComplete=true;fieldIndexExpectedCount=0;fieldIndexLoadedCount=0;fieldIndexAfterNumber=-1;cloudOutboxReady=true;statsDirty=true;cachedStats={solved:0,score:0,earned:0};inventoryCache=null;spentScoreCache=null;
|
||
return data;
|
||
}
|
||
async function waitForRealtimeReady(timeout=7000){
|
||
if(realtimeReady)return true;connectRealtime();const started=Date.now();while(!realtimeReady&&Date.now()-started<timeout){await new Promise(resolve=>setTimeout(resolve,50));if(!cloudAvailable)break}return realtimeReady;
|
||
}
|
||
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:normalizeSpecialMechanics(data.specialMechanicsSeen),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])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=16,preferredBoardId=null){
|
||
const batch=emptyCloudPending(),remainder=emptyCloudPending(),maximum=Math.max(1,Math.floor(limit)||1);
|
||
batch.globalChanged=source.globalChanged===true;
|
||
const boardIds=[...new Set([...(source.stateIds||[]),...(source.metaIds||[])])],preferred=boardIds.includes(preferredBoardId)?preferredBoardId:boardIds[0],selectedBoardIds=new Set(preferred?[preferred]:[]);
|
||
for(const key of['metaIds','stateIds'])for(const id of source[key]||[])(selectedBoardIds.has(id)?batch:remainder)[key].add(id);
|
||
let deletedUsed=selectedBoardIds.size;
|
||
for(const id of source.deleted||[])(deletedUsed++<maximum?batch:remainder).deleted.add(id);
|
||
return{batch,remainder};
|
||
}
|
||
function cloudPendingHasWork(pending=cloudPushPending){return pending.globalChanged||pending.metaIds.size>0||pending.stateIds.size>0||pending.deleted.size>0}
|
||
function cloudMutationId(payload,changeSeq){const signature=JSON.stringify(payload);let value=2166136261;for(let index=0;index<signature.length;index++)value=Math.imul(value^signature.charCodeAt(index),16777619);const hash=(value>>>0).toString(16).padStart(8,'0'),owner=String(sessionId||'client').replace(/[^A-Za-z0-9_-]/g,'').slice(0,40)||'client';return`${owner}-${Math.max(0,Number(changeSeq)||0).toString(36)}-${hash}`}
|
||
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 statusProbe=url==='/api/cloud/status'&&(!options.method||String(options.method).toUpperCase()==='GET'),bases=statusProbe?[cloudApiBaseUrl,...cloudApiBaseCandidates.filter(candidate=>candidate!==cloudApiBaseUrl)]:[cloudApiBaseUrl];let lastError=null;
|
||
for(let index=0;index<bases.length;index++){
|
||
const base=bases[index],controller=new AbortController(),timer=setTimeout(()=>controller.abort(),timeout);
|
||
try{
|
||
const endpoint=cloudEndpointUrl(url,base),response=await fetch(endpoint,{...options,signal:controller.signal,headers:{'content-type':'application/json',...(options.headers||{})}}),body=await response.json().catch(()=>({}));
|
||
if(statusProbe&&response.ok&&body?.available===true){cloudApiBaseUrl=base;cloudApiPhpBridgeActive=response.headers.get('x-linkfield-bridge')==='php'||base===cloudApiBridgeUrl;return body}
|
||
if(!response.ok){const error=new Error(body.error||`HTTP ${response.status}`);error.status=response.status;error.body=body;throw error}
|
||
if(statusProbe&&index<bases.length-1){lastError=new Error('Cloud status endpoint did not return a shared-world response');continue}
|
||
return body;
|
||
}catch(error){lastError=error;if(!statusProbe||index===bases.length-1)throw error}
|
||
finally{clearTimeout(timer)}
|
||
}
|
||
throw lastError||new Error('Cloud API is unavailable');
|
||
}
|
||
function cloudAuthHeaders(){if(!data.cloudProfile)return{};const value=`Bearer ${data.cloudProfile.playerId}.${data.cloudProfile.token}`;return{authorization:value,'x-linkfield-authorization':value}}
|
||
function applyPlayerEconomyEnvelope(raw){
|
||
const player=raw?.player||raw;if(!player||!Array.isArray(player.purchases))return false;
|
||
const purchases=new Map();for(const purchase of[...normalizePlayerPurchases(data.playerPurchases),...normalizePlayerPurchases(player.purchases)]){const key=purchase.purchaseId||`${purchase.boardId}:${purchase.itemId}`;const prior=purchases.get(key);if(!prior||(purchase.boughtAt||0)>=(prior.boughtAt||0))purchases.set(key,purchase)}
|
||
const previousStarter=data.starterLineColor,starter=validStarterLineColorId(player.starterLineColor)?player.starterLineColor:starterLineColorForPlayer(data.cloudProfile?.playerId);
|
||
data.playerPurchases=normalizePlayerPurchases(player.purchases);data.playerEarnedScore=Number.isSafeInteger(player.earnedScore)&&player.earnedScore>=0?player.earnedScore:0;data.starterLineColor=starter;if(!validLineColorItemId(data.lineColorStyle)||data.lineColorStyle===previousStarter)data.lineColorStyle=starter;
|
||
playerEconomyLoaded=true;invalidateEconomyCaches();syncCosmeticAppearance();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(){if(!cloudBtn)return;const state=!cloudAvailable?'offline':cloudSyncing?'syncing':realtimeReady?'online':'connecting';cloudBtn.dataset.state=state;cloudBtn.title=state==='online'?'共有サーバーに接続済み':state==='offline'?'共有サーバーに接続できません':'共有サーバーへ接続中';cloudBtn.setAttribute('aria-label',cloudBtn.title)}
|
||
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)}) · レベル${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}if(Array.isArray(result?.player?.purchases))applyPlayerEconomyEnvelope(result);
|
||
if(result?.clearEventsGap===true&&!initial){console.warn('LinkField: shared clear-event history gap; state was resynchronized from the authoritative snapshot');toast('\u5171\u6709\u5c65\u6b74\u306e\u4e00\u90e8\u3092\u518d\u540c\u671f\u3057\u307e\u3057\u305f\u3002',3000)}
|
||
const latest=Math.max(0,Number(result?.latestEventRevision)||0),events=Array.isArray(result?.clearEvents)?result.clearEvents:[];
|
||
for(const event of events)if(event?.id)removeClaim(event.id,'cleared');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();const previousStarter=data.starterLineColor;data.cloudProfile={playerId:result.playerId,token:result.token};data.playerName=result.name||data.playerName||null;if(typeof validStarterLineColorId==='function'&&validStarterLineColorId(result.starterLineColor)){data.starterLineColor=result.starterLineColor;if(!validLineColorItemId(data.lineColorStyle)||data.lineColorStyle===previousStarter)data.lineColorStyle=result.starterLineColor;if(typeof syncCosmeticAppearance==='function')syncCosmeticAppearance()}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(interactionActive()||[...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,reopenedAfterPull=0;
|
||
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))}resolveMergedOverlaps();statsDirty=true;markGlobalDirty(false)}finally{cloudApplyingRemote=false}reopenedAfterPull=reopenMissingGateExpansions();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();pulled=true;return true;
|
||
}catch(error){setCloudStatus();console.warn('BEND FIELD: shared-world pull failed',error);return false}
|
||
finally{cloudSyncing=false;setCloudStatus();restoreCloudPushPending();if(reopenedAfterPull)scheduleExpansionRepair(150);if(cloudPendingHasWork())armCloudPush(pulled?250:5000)}
|
||
}
|
||
function scheduleCloudPush(signal={},delay=120){mergeCloudPending(cloudPushPending,signal);armCloudPush(delay)}
|
||
async function pushCloudPending(preferredBoardId=null){
|
||
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,16,preferredBoardId),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:[]};payload.mutationId=cloudMutationId(payload,sentCloudChangeSeq);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();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();return false}setCloudStatus();return true;
|
||
}catch(error){mergeCloudPending(cloudPushPending,pending);if(error.status===423&&payload){cloudSyncing=false;const boardId=error.body?.boardId||payload.states?.[0]?.id,claimed=boardId?await requestBoardClaim(boardId,{allowLocalSolved:true}):false;if(!claimed){const pulled=await pullCloudWorld(true,0,{authoritative:true});retryDelay=pulled?500:5000}else retryDelay=0;setCloudStatus()}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()}else{retryDelay=5000;setCloudStatus();console.warn('BEND FIELD: shared-world push failed',error)}return false}
|
||
finally{cloudSyncing=false;setCloudStatus();if(cloudPendingHasWork())armCloudPush(retryDelay)}
|
||
}
|
||
function scheduleWorldPoll(delay=document.visibilityState==='visible'?30000:120000){clearTimeout(worldPollTimer);if(!cloudAvailable||!data.cloudProfile)return;worldPollTimer=setTimeout(async()=>{worldPollTimer=0;if(document.visibilityState==='visible')await pullCloudWorld();scheduleWorldPoll()},Math.max(5000,delay))}
|
||
function scheduleRemoteWorldPull(revision,delay=60){
|
||
remoteWorldRevision=Math.max(remoteWorldRevision,Math.max(0,Number(revision)||0));if(remoteWorldRevision<=(data.cloudRevision||0))return false;
|
||
clearTimeout(remoteWorldPullTimer);remoteWorldPullTimer=setTimeout(async()=>{remoteWorldPullTimer=0;if(remoteWorldRevision<=(data.cloudRevision||0))return;const pulled=await pullCloudWorld();if(!pulled&&remoteWorldRevision>(data.cloudRevision||0))scheduleRemoteWorldPull(remoteWorldRevision,500)},Math.max(0,delay));return true;
|
||
}
|
||
async function fetchCurrentSharedWorldStatus(){
|
||
let lastStatus=null,lastError=null;
|
||
for(let attempt=0;attempt<6;attempt++){
|
||
try{
|
||
const status=await fetchJson('/api/cloud/status',{},8000);lastStatus=status;
|
||
const identityOk=status.available===true&&status.sharedWorld===true&&status.singleWorld===true&&status.worldId==='link-field-main';
|
||
const generationOk=status.worldGeneration===WORLD_GENERATION;
|
||
const versionOk=status.appVersion===APP_VERSION;
|
||
if(identityOk&&generationOk&&versionOk)return status;
|
||
}catch(error){lastError=error}
|
||
if(attempt<5)await new Promise(resolve=>setTimeout(resolve,400));
|
||
}
|
||
if(lastStatus)throw new Error(`共有サーバーの版が一致しません。サーバー側で npm start を実行してください。(ブラウザ v${APP_VERSION}/サーバー v${lastStatus.appVersion||'不明'})`);
|
||
throw lastError||new Error('単一共有ワールドへ接続できません。サーバーを確認してください。');
|
||
}
|
||
async function initCloudSync({startup=false}={}){
|
||
let status;try{status=await fetchCurrentSharedWorldStatus()}catch(error){cloudAvailable=false;setCloudStatus();throw error}
|
||
cloudAvailable=true;serverClockOffset=status.serverTime-Date.now();cloudOutboxReady=true;
|
||
if(!data.cloudProfile)await createCloudProfile();
|
||
if(startup){
|
||
const serverRevision=Math.max(0,Number(status.revision)||0);resetClientToSingleSharedWorld();await activateSingleSharedClientCache();
|
||
if(serverRevision>0){if(!await pullCloudWorld(true,0,{initial:true,authoritative:true}))throw new Error('共有盤面を取得できませんでした。')}
|
||
else{
|
||
await ensureStart();restoreCloudPending({metaIds:Object.keys(data.metas),stateIds:Object.keys(data.states),deleted:[],globalChanged:true});restoreCloudPushPending();
|
||
if(!await persistNow({skipCloud:true}))throw new Error('共有盤面の初期キャッシュを準備できませんでした。');
|
||
if(!await pushCloudPending()){
|
||
const refreshed=await fetchJson('/api/cloud/status',{},8000);if((Number(refreshed.revision)||0)<=0)throw new Error('共有盤面を初期化できませんでした。');
|
||
if(!await pullCloudWorld(true,0,{initial:true,authoritative:true}))throw new Error('共有盤面を取得できませんでした。');
|
||
}else if(!await pullCloudWorld(true,0,{initial:true,authoritative:true}))throw new Error('共有盤面を確認できませんでした。');
|
||
}
|
||
}else if(!await pullCloudWorld(false,null,{initial:false}))return false;
|
||
await pullPlayerEconomy();setCloudStatus();connectRealtime();if(!await waitForRealtimeReady())throw new Error('盤面占有サービスへ接続できませんでした。');scheduleRealtimeViewport(true);scheduleWorldPoll();return true;
|
||
}
|
||
|
||
async function changePlayerNameFromTopUi(){
|
||
const entered=prompt('プレイヤー名を入力してください(24文字まで)。',currentPlayerName());if(entered==null)return;
|
||
try{const name=await commitPlayerProfileName(entered);toast(`プレイヤー名を「${name}」に変更しました。`)}
|
||
catch(error){toast('名前を変更できませんでした。')}
|
||
}
|
||
|
||
if(cloudBtn)cloudBtn.onclick=null;
|
||
|
||
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);
|
||
cloudOutboxReady=true;restoreCloudPending(normalizeCloudPending(null));restoreCloudPushPending();
|
||
normalizeEquippedCosmeticsInPlace(data);data.debugAllItems=false;applyUiSettings();syncCursorAppearance(data.cursorStyle||'default');syncCosmeticAppearance();document.body.dataset.debugItems=debugAllItemsEnabled()?'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();
|
||
await initCloudSync({startup:true});
|
||
await ensureStart();
|
||
let initialMeta=await randomUnsolvedMeta()||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(()=>{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);
|
||
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);
|
||
}
|
||
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:false,onRetry:()=>location.reload()});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});
|