-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathoverlay.js
More file actions
2128 lines (1976 loc) · 80.3 KB
/
Copy pathoverlay.js
File metadata and controls
2128 lines (1976 loc) · 80.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
const phaseLabel = document.querySelector("#phaseLabel");
const monsterName = document.querySelector("#monsterName");
const monsterStage = document.querySelector("#monsterStage");
const roster = document.querySelector("#roster");
const allies = document.querySelector("#allies");
const toast = document.querySelector("#toast");
const sceneBg = document.querySelector("#sceneBg");
const heroImage = document.querySelector("#heroImage");
const monsterImage = document.querySelector("#monsterImage");
const audioButton = document.querySelector("#audioButton");
const resetButton = document.querySelector("#resetButton");
const townButton = document.querySelector("#townButton");
const fieldAudio = document.querySelector("#fieldAudio");
const adventureAudio = document.querySelector("#adventureAudio");
const battleAudio = document.querySelector("#battleAudio");
const dungeonAdventureAudio = document.querySelector("#dungeonAdventureAudio");
const dungeonBattleAudio = document.querySelector("#dungeonBattleAudio");
const castleAdventureAudio = document.querySelector("#castleAdventureAudio");
const castleBattleAudio = document.querySelector("#castleBattleAudio");
const SPRITE_CACHE_BUSTER = Date.now().toString(36);
const canvas = document.querySelector("#fxCanvas");
const stage = document.querySelector(".stage");
const ctx = canvas.getContext("2d");
const phaseText = {
idle: "Town",
field: "Explore",
battle: "Battle",
complete: "Clear"
};
const stageBackgrounds = {
field: "/assets/field.png",
dungeon: "/assets/dungeon.png",
castle: "/assets/castle.png"
};
const spriteByName = {
slime: "slime",
goblin: "goblin",
orc: "orc",
ogre: "ogre",
skeleton: "skeleton",
ghoul: "ghoul",
witch: "witch",
"grim-reaper": "grim-reaper",
succubus: "succubus",
dullahan: "dullahan",
dragon: "dragon",
"demon-lord": "demon-lord",
"dark-mage": "dark-mage",
"wolf-beastwoman": "wolf-beastwoman",
"dark-knight": "dark-knight"
};
const svgSpriteNames = new Set();
const TRACK_FILES = {
field: fieldAudio,
adventure: adventureAudio,
battle: battleAudio,
"dungeon-adventure": dungeonAdventureAudio,
"dungeon-battle": dungeonBattleAudio,
"castle-adventure": castleAdventureAudio,
"castle-battle": castleBattleAudio
};
const ATTACK_SFX = {
hero: {
normal: "hero-normal-attack",
skill: "hero-skill-attack",
finisher: "hero-finisher-attack"
},
ally: {
fire: "ally-fire-attack",
earth: "ally-earth-attack",
wind: "ally-wind-attack",
water: "ally-water-attack"
}
};
const HERO_ATTACK_AUDIO = {
normal: {
sfx: ATTACK_SFX.hero.normal,
noises: [
{ delay: 0, duration: 0.18, volume: 0.14, cutoff: 2600 },
{ delay: 0.08, duration: 0.1, volume: 0.08, cutoff: 3800 }
],
thumps: [{ midi: 35, delay: 0.1, duration: 0.14, type: "sawtooth", volume: 0.05 }]
},
skill: {
sfx: ATTACK_SFX.hero.skill,
noises: [
{ delay: 0, duration: 0.22, volume: 0.15, cutoff: 2800 },
{ delay: 0.13, duration: 0.16, volume: 0.12, cutoff: 3400 }
],
thumps: [{ midi: 34, delay: 0.17, duration: 0.18, type: "sawtooth", volume: 0.06 }]
},
finisher: {
sfx: ATTACK_SFX.hero.finisher,
noises: [
{ delay: 0, duration: 0.24, volume: 0.16, cutoff: 2900 },
{ delay: 0.15, duration: 0.28, volume: 0.15, cutoff: 3200 },
{ delay: 0.28, duration: 0.18, volume: 0.12, cutoff: 1600 }
],
thumps: [{ midi: 29, delay: 0.25, duration: 0.32, type: "sawtooth", volume: 0.1 }]
}
};
const ALLY_ATTACK_AUDIO = {
fire: {
sfx: ATTACK_SFX.ally.fire,
noises: [
{ delay: 0, duration: 0.42, volume: 0.18, cutoff: 1100 },
{ delay: 0.08, duration: 0.26, volume: 0.08, cutoff: 2200 }
],
thumps: [{ midi: 31, delay: 0.05, duration: 0.36, type: "sawtooth", volume: 0.055 }]
},
earth: {
sfx: ATTACK_SFX.ally.earth,
noises: [
{ delay: 0, duration: 0.32, volume: 0.2, cutoff: 950 },
{ delay: 0.16, duration: 0.28, volume: 0.1, cutoff: 1500 }
],
thumps: [{ midi: 24, delay: 0, duration: 0.38, type: "sawtooth", volume: 0.14 }]
},
wind: {
sfx: ATTACK_SFX.ally.wind,
noises: [
{ delay: 0, duration: 0.12, volume: 0.12, cutoff: 3800 },
{ delay: 0.12, duration: 0.12, volume: 0.12, cutoff: 4200 },
{ delay: 0.24, duration: 0.16, volume: 0.14, cutoff: 4600 }
],
thumps: [{ midi: 38, delay: 0.3, duration: 0.12, type: "sawtooth", volume: 0.045 }]
},
water: {
sfx: ATTACK_SFX.ally.water,
noises: [
{ delay: 0, duration: 0.42, volume: 0.18, cutoff: 1800 },
{ delay: 0.06, duration: 0.32, volume: 0.1, cutoff: 3000 }
],
thumps: [{ midi: 33, delay: 0.08, duration: 0.24, type: "sawtooth", volume: 0.06 }]
}
};
const MUSIC = {
field: {
bpm: 126,
lead: [67, 71, 74, 79, 81, 79, 76, 74, 72, 76, 79, 83, 84, 83, 79, 76, 71, 74, 79, 83, 86, 84, 83, 79, 76, 74, 72, 71, 69, 71, 74, 79],
counter: [null, null, 55, 59, 62, null, 59, 55, null, null, 57, 60, 64, null, 60, 57, null, null, 59, 62, 66, null, 62, 59, null, null, 55, 59, 62, 64, 66, 67],
bass: [43, 43, 50, 50, 55, 55, 50, 50, 45, 45, 52, 52, 57, 57, 52, 52],
chords: [
[43, 50, 55, 59],
[45, 52, 57, 60],
[47, 54, 59, 62],
[48, 55, 60, 64],
[50, 57, 62, 66],
[52, 59, 64, 67],
[48, 55, 60, 64],
[50, 57, 62, 66]
],
arp: [0, 2, 1, 3, 2, 1, 0, 2],
wave: "square",
leadVolume: 0.09,
padVolume: 0.026,
arpVolume: 0.034,
counterVolume: 0.045,
mood: "wide"
},
battle: {
bpm: 168,
lead: [52, 55, 59, 64, 63, 59, 55, 52, 54, 57, 61, 66, 65, 61, 57, 54, 59, 62, 66, 71, 70, 66, 62, 59, 57, 61, 64, 69, 68, 64, 61, 57],
counter: [40, null, 47, 52, 51, null, 47, 40, 42, null, 49, 54, 53, null, 49, 42, 47, null, 54, 59, 58, null, 54, 47, 45, null, 52, 57, 56, null, 52, 45],
bass: [28, 28, 40, 28, 28, 40, 35, 40, 30, 30, 42, 30, 30, 42, 37, 42],
chords: [
[28, 40, 47, 52],
[30, 42, 49, 54],
[32, 44, 51, 56],
[35, 47, 52, 59],
[33, 45, 52, 57],
[30, 42, 49, 54],
[35, 47, 54, 59],
[28, 40, 47, 52]
],
arp: [0, 1, 2, 3, 2, 1, 3, 2],
wave: "sawtooth",
leadVolume: 0.105,
padVolume: 0.032,
arpVolume: 0.045,
counterVolume: 0.052,
mood: "urgent"
}
};
let currentTrack = "silence";
let particles = [];
let shakeTimer = null;
let monsterActionTimer = null;
let lastRenderedMonster = null;
let worldVisualsHeld = false; // 撃破演出中だけ true:精霊カードの即時消去を保留する(ally_return が1体ずつ帰す)
let currentHook = null; // いま処理中の state 更新の由来 Hook(origin 形)。world 効果の帰属に使う。
let latestState = null;
let latestAllies = []; // 最新の在席精霊(state.allies)。精霊追撃をフロント生成する時の名簿スナップショット。
// 召喚エフェクトが付いて来た精霊の id 集合。召喚も攻撃キューと同じ扱い=召喚がキューで再生される(appear-hold明け)まで
// カードを出さない(出現演出と被らせない)。ally_summon 再生時に外し、その瞬間にバーストと同時へカードを出す。
const awaitingSummon = new Set();
let audio = {
enabled: false,
ctx: null,
gain: null,
compressor: null,
players: null,
activeTrack: "silence",
userMuted: false,
timer: null,
step: 0,
scheduledTrack: "silence",
nextTime: 0
};
// ミュート希望はページ再読込(新セッション/ハブ再起動で overlay は何度も再ロードされる)を跨いで保持する。
// これが無いと再読込のたび userMuted=false に戻り、applyWorld の自動有効化で BGM が勝手に復活する
// (=マルチセッション運用で「音 ON/OFF ボタンが効かない」ように見える不具合の主因)。
try {
if (localStorage.getItem("rpgdev.audioMuted") === "1") audio.userMuted = true;
} catch (_) {}
function persistAudioMuted(muted) {
try {
localStorage.setItem("rpgdev.audioMuted", muted ? "1" : "0");
} catch (_) {}
}
new EventSource("/events").addEventListener("state", (message) => {
const payload = JSON.parse(message.data);
const effectList = (payload.effects || []).slice();
currentHook = hookOrigin(payload.event); // この更新を起こした Hook(初期スナップショット/reset は null)
const hasDefeat = effectList.some((effect) => effect.type === "monster_defeated");
// 撃破バッチは精霊カードの即時消去を保留(ally_return が1体ずつ帰す。末尾の world 効果で最終同期)。
if (hasDefeat) worldVisualsHeld = true;
// あらゆる画面変化を「一本のキュー」に集約する:背景/BGM/phase/シーンの遷移も world 効果として
// effectList の末尾へ積む。撃破バッチなら finisher→撃破→精霊帰還→world(背景切替) の順に直列化され、
// 「精霊が全員帰ってから背景が変わる」が自然に保証される(旧 holdWorldVisuals のタイマー hack を廃止)。
const worldEffect = diffWorldEffect(payload.state, hasDefeat);
if (worldEffect) effectList.push(worldEffect);
prepareMonsterEffects(effectList);
// 召喚エフェクト付きの精霊は、その召喚がキューで再生されるまでカードを伏せる(攻撃キューと同じ扱い)。
for (const effect of effectList) {
if (effect.type === "ally_summon" && effect.ally?.id) awaitingSummon.add(effect.ally.id);
// 精霊の被弾/退場/帰還エフェクトの画面位置は、ここ(render が当該カードを除去する前=カードが確実に在る今)で確定する。
// 再生時にカードを引かない=消えてから位置を読んで中央へフォールバックする不具合を根本から無くす(原因隠しを廃止)。
if (effect.type === "ally_return" || effect.type === "ally_defeated" || effect.type === "ally_hit") {
effect.allyCenter = allyCardCenter(effect.allyId);
}
}
render(payload.state);
effects(effectList);
});
// --- 内部トレース(演出の再生/取りこぼし/世界遷移を由来 Hook 付きで記録)---
// overlay(デスクトップ窓の本体 UI)が「実際に何を再生し、何を捨て、いつ待たせたか」を
// サーバの /trace へ送り、.rpgdev/playback.ndjson に残す。reducer の emit ログ
// (.rpgdev/events.ndjson) と origin.seq で突き合わせれば、二連続/欠落の原因を解析できる。
let traceN = 0;
function trace(record) {
const line = { view: "overlay", n: (traceN += 1), t: Date.now(), ...record };
try {
fetch("/trace", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(line),
keepalive: true
}).catch((error) => console.error("[rpgdev] trace POST failed", error));
} catch (error) {
// トレースは診断専用。失敗してもゲーム進行は止めないが、黙って握りつぶさず必ず記録に残す。
console.error("[rpgdev] trace failed", error);
}
}
// 正規化 Hook(payload.event)を effect.origin と同形の由来情報へ変換する。
function hookOrigin(hookEvent) {
if (!hookEvent || typeof hookEvent !== "object") return null;
return {
seq: hookEvent.seq,
hookId: hookEvent.id,
event: hookEvent.event,
provider: hookEvent.provider,
tool: hookEvent.toolName || null,
at: hookEvent.at
};
}
// 攻撃 effect の簡易ラベル(トレースの可読性用)。
function effectTag(effect) {
if (!effect) return null;
if (effect.type === "attack") {
if (effect.kind === "ally") return `attack:ally:${effect.allyElement || "spirit"}`;
if (effect.kind === "skill") return `attack:skill:${effect.skill || "?"}`;
return "attack:normal";
}
return effect.type;
}
audioButton.addEventListener("click", async () => {
if (audio.enabled) {
audio.enabled = false;
audio.userMuted = true;
persistAudioMuted(true); // 再読込を跨いでミュートを保持
audioButton.classList.remove("is-on");
stopMusic();
return;
}
ensureTrackPlayers();
if (!postNativeAudio({ enabled: true, track: currentTrack })) {
ensureEffectAudio();
}
audio.enabled = true;
audio.userMuted = false;
persistAudioMuted(false);
audioButton.classList.add("is-on");
setTrack(currentTrack);
});
resetButton?.addEventListener("click", async () => {
await fetch("/control/reset", { method: "POST" });
});
townButton?.addEventListener("click", () => {
// 手動「街に戻る」:今の冒険を終えて拠点へ戻し、オーナーを解放する(無反応オーナーで固まった時の即時復旧)。
fetch("/control/return-town", { method: "POST" }).catch((error) =>
console.error("[rpgdev] return-town POST failed", error)
);
});
requestAnimationFrame(draw);
function render(state) {
latestState = state;
latestAllies = state.allies || []; // 精霊追撃のフロント生成に使う最新名簿
// state から消えた精霊(帰還/被弾退場/リセット)は召喚待ち集合からも除く=取り残してカードを伏せ続けない。
if (awaitingSummon.size) {
const live = new Set(latestAllies.map((ally) => ally.id));
for (const id of awaitingSummon) if (!live.has(id)) awaitingSummon.delete(id);
}
// 背景/BGM/phase/シーンは render では適用しない=キューの world 効果が順番に適用する(単一キューへ集約)。
renderRoster(state.quest || [], state.phase);
// 撃破演出中(worldVisualsHeld)は精霊カードを即時に消さず保留する。
// 帰還は ally_return が1体ずつアニメ付きで行い、末尾の world 効果で最終状態(=空)を反映する。
if (!worldVisualsHeld) renderAllies(state.allies || []);
const monsters = state.monsters || [];
const target = monsters.find((m) => m.status === "in_progress") || null;
if (!target) {
// 探検中(in_progress なし)または待機: 戦闘相手を出さない
// 撃破がキュー/進行中の間は、敵不在 render でモンスターを隠さない(経路Bの早隠しを止める)。
// 脆い dataset.action(出現タイマーが delete しうる)だけに頼らず、撃破の真の状態で判定する=
// 「素早く消える→一瞬戻る→破片で消える」の二重消滅を無くし、最終消滅は撃破アニメ1回だけにする。
if (
monsterStage.dataset.action === "defeat" ||
monsterStage.dataset.action === "defeat-pending" ||
monsterDefeatInProgress ||
worldVisualsHeld ||
fxQueue.some((e) => e.type === "monster_defeated")
)
return;
monsterStage.dataset.active = "false";
monsterStage.dataset.dying = "false";
monsterName.textContent = "";
return;
}
const sprite = monsterSprite(target);
monsterStage.dataset.active = "true";
monsterStage.dataset.dying = target.dying ? "true" : "false";
setMonsterSprite(sprite);
monsterName.textContent = "";
lastRenderedMonster = { ...target, sprite };
}
const worldPrev = { phase: null, stage: null, track: null }; // 直前に「キューへ積んだ」世界状態(差分検出用)
// 背景/BGM/phase/シーンの遷移をキュー項目(world 効果)に変換する。
// 変化が無く撃破解除も不要なら null。撃破バッチ(releaseDefeat)では、保留した精霊カードの最終同期のため
// 変化が無くても world 効果を出す。worldPrev は「積んだ時点」で更新し、実際の適用は再生時(applyWorld)に行う。
function diffWorldEffect(state, releaseDefeat) {
const phase = state.phase;
const stage = adventureStage(state);
const track = state.currentTrack || "field";
const changed = phase !== worldPrev.phase || stage !== worldPrev.stage || track !== worldPrev.track;
if (!changed && !releaseDefeat) return null;
const from = { ...worldPrev };
worldPrev.phase = phase;
worldPrev.stage = stage;
worldPrev.track = track;
// 全画面トランジションで被覆する遷移:
// ① 戦闘→探検/街(battle→field/complete)=勇者配置の瞬間移動を隠す(要件5)。
// ② 街→探検(idle/complete→field)=街から冒険へ入る切り替えを演出する。
// from.phase は初回スナップショット/reset では null なので、明示的な idle/complete からの遷移だけが対象。
const leavingBattle = from.phase === "battle" && (phase === "field" || phase === "complete");
const enteringFieldFromTown = (from.phase === "idle" || from.phase === "complete") && phase === "field";
const transition = leavingBattle || enteringFieldFromTown;
return {
type: "world",
phase,
stage,
track,
from,
releaseDefeat: Boolean(releaseDefeat),
transition,
label: transition ? transitionLabel(phase, stage) : null,
origin: currentHook
};
}
// world 効果の再生=背景/BGM/勇者スプライト/phase を実際に切り替える(単一キューの中で順番に適用)。
function applyWorld(effect) {
const phase = effect.phase;
const stageName = stageBackgrounds[effect.stage] ? effect.stage : "field";
document.body.dataset.phase = phase;
document.body.dataset.adventureStage = stageName;
phaseLabel.textContent = phaseText[phase] || phase;
const isResting = phase === "idle" || phase === "complete";
sceneBg.src = isResting ? "/assets/town.png" : stageBackgrounds[stageName];
heroImage.src = phase === "battle"
? "/assets/sprites/hero-battle.png"
: isResting
? "/assets/sprites/hero-relax.png"
: "/assets/sprites/hero.png";
currentTrack = effect.track || "field";
if (!audio.enabled && !audio.userMuted) {
audio.enabled = true;
audioButton.classList.add("is-on");
// ネイティブ音声ブリッジが無い環境(Windows/ブラウザ)では、自動有効化時に SFX 用 WebAudio コンテキストも用意する。
// applyWorld は button クリック(ensureEffectAudio 済み)を経由しないため、これが無いと audio.ctx が null のまま=
// 攻撃などの WebAudio SFX が鳴らない(攻撃スペックは notes を持たず sting=自己修復経路を通らないため)。BGM は <audio> なので鳴る。
if (!hasNativeAudioBridge()) ensureEffectAudio();
}
setTrack(currentTrack);
// どの Hook でフェーズ/ステージ(フィールド前進・街帰還)/BGM が変わったかを記録する。
traceWorldTransition("phase", effect.from.phase, phase, effect.origin);
traceWorldTransition("stage", effect.from.stage, stageName, effect.origin);
traceWorldTransition("track", effect.from.track, currentTrack, effect.origin);
}
// 世界状態(phase/stage/track)が変化したら、その由来 Hook と共にトレースする。
// Hook 不在(初期スナップショット/reset)の変化は記録を出さない。
function traceWorldTransition(field, from, to, hook) {
if (from === to || !hook) return;
trace({ kind: "world", field, from, to, origin: hook });
}
function adventureStage(state) {
const stage = state?.adventureStage || "field";
return stageBackgrounds[stage] ? stage : "field";
}
// パネルが溢れないように表示する最大行数(超過分は最古の達成項目だけ畳む)。
const QUEST_MAX_ROWS = 9;
// クエスト(ミッション)トラッカー: 最新 TodoWrite スナップショットを MMO 風の一覧で描画。
// 未着手 ◇ / 進行中(現在の討伐対象)◆ / 達成 ✓。街(idle)では表示しない。
function renderRoster(quest, phase) {
if (!roster) return;
const items = Array.isArray(quest) ? quest.filter((it) => it && it.label) : [];
// 全項目が完了していたらクエストウィンドウは消す(残さない)。街(idle/complete=待機)でも出さない。
// ターン終了(complete)で街に戻ったら、未討伐の TODO が残っていてもクエスト窓は畳む
// (AI が TODO に止めを刺さず complete になることがあり、街で TODO が残ると違和感が出るため)。
const allDone = items.length > 0 && items.every((it) => it.status === "completed");
if (!items.length || phase === "idle" || phase === "complete" || allDone) {
roster.dataset.active = "false";
roster.replaceChildren();
return;
}
roster.dataset.active = "true";
const total = items.length;
const doneCount = items.filter((it) => it.status === "completed").length;
// 進行中・未着手は必ず残し、行が多すぎるときだけ先頭(最古の達成)を畳む。
let visible = items;
let folded = 0;
if (items.length > QUEST_MAX_ROWS) {
folded = items.length - QUEST_MAX_ROWS;
visible = items.slice(folded);
}
const head = document.createElement("div");
head.className = "roster-head";
const crest = document.createElement("span");
crest.className = "roster-crest";
crest.textContent = "❖";
const title = document.createElement("span");
title.className = "roster-title";
// TODO(TodoWrite/update_plan)由来は「連続」、TODO 不在時のユーザー入力(synthetic)は「単発」。
const isSynthetic = items.some((it) => it.synthetic);
title.textContent = isSynthetic ? "Quest (one-off)" : "Quest (ongoing)";
const count = document.createElement("span");
count.className = "roster-count";
count.textContent = `${doneCount} / ${total}`;
head.append(crest, title, count);
const list = document.createElement("div");
list.className = "roster-list";
if (folded > 0) {
list.append(questRow("completed", `ほか ${folded} 件 達成`, true));
}
for (const item of visible) {
list.append(questRow(item.status, item.label, false));
}
roster.replaceChildren(head, list);
}
function questRow(status, label, folded) {
const kind = status === "completed" ? "done" : status === "in_progress" ? "active" : "todo";
const row = document.createElement("div");
row.className = `roster-item is-${kind}${folded ? " is-folded" : ""}`;
const mark = document.createElement("span");
mark.className = "roster-mark";
mark.textContent = kind === "done" ? "✓" : kind === "active" ? "◆" : "◇";
const text = document.createElement("span");
text.className = "roster-text";
text.textContent = label;
row.append(mark, text);
return row;
}
function renderAllies(list) {
if (!allies) return;
// 召喚待ち(awaitingSummon)の精霊は、ally_summon がキューで再生されるまでカードを出さない(攻撃キューと同じ扱い)。
const visible = list.filter((ally) => !(ally.id && awaitingSummon.has(ally.id)));
if (!visible.length) {
allies.dataset.active = "false";
allies.replaceChildren();
return;
}
allies.dataset.active = "true";
allies.replaceChildren(
...visible.slice(-4).map((ally, index) => {
const card = document.createElement("div");
card.className = `ally ally-${ally.element || "spirit"}`;
card.dataset.allyId = ally.id || "";
card.style.setProperty("--slot", index);
const image = document.createElement("img");
image.src = allySpritePath(allyRenderSprite(ally));
image.alt = "";
const name = document.createElement("span");
name.textContent = ally.name || "Spirit";
card.append(image, name);
return card;
})
);
}
function monsterSprite(monster) {
if (monster.sprite && spriteByName[monster.sprite]) return monster.sprite;
const name = String(monster.name || "").toLowerCase();
for (const [key, sprite] of Object.entries(spriteByName)) {
if (name.includes(key)) return sprite;
}
return "goblin";
}
// 攻撃アニメは常に1体ずつ(勇者を含む)。攻撃キューは 1 秒間隔で次へ。
// 演出はグローバルなキューで直列化し、複数バッチが重なって連続再生されないようにする。
let fxQueue = [];
let fxBusy = false;
let monsterDefeatInProgress = false;
let appearAttackHoldUntil = 0; // この時刻(ms)まで attack の再生を保留(出現演出と被らせない)
const ANIM_GAP = 100; // アニメ間の空き(0.1 秒)
// 勇者・精霊の攻撃と精霊召喚は、種別を問わず前のキュー再生開始から1秒後に次を再生する(前のキューが無ければ即座)。
const ATTACK_QUEUE_INTERVAL_MS = 1000;
// キュー再生はモンスター登場の4秒後に開始する(出現演出を見せ切ってから初撃/召喚)。サーバーの最低在席時間
// (MIN_MONSTER_LIFETIME_MS=4s)と一致=登場4秒後の初撃が、討伐可能になる瞬間とちょうど揃う。
const APPEAR_ATTACK_DELAY_MS = 4000;
const MAX_QUEUED_ATTACKS = 10; // 詰まりすぎ防止(超過した攻撃アニメは間引く)
// --- モンスターの反撃ループ(要件2)---
// 生存モンスターが居て、勇者+全精霊の攻撃を再生し切り(キュー枯渇)、出現演出も明けたら、
// 8秒おきにモンスターが反撃する。対象は勇者と在席精霊からランダム。タイミングは実クロックを持つ
// フロントだけが駆動できる(reducer はタイマー非保持=§12)。精霊に当たればサーバーへ通知してライフ確定。
const COUNTER_INTERVAL_MS = 8000;
let counterTimer = null;
let counterSeq = 0;
function startCounterLoop() {
if (counterTimer) return;
counterTimer = window.setInterval(runCounterTick, COUNTER_INTERVAL_MS);
}
function stopCounterLoop() {
if (!counterTimer) return;
window.clearInterval(counterTimer);
counterTimer = null;
}
// 反撃を許す条件:モンスター在席・撃破処理中でない・出現演出が明けている・キューが空(攻撃を全部再生済み)。
function counterLoopAllowed() {
return (
!latestState?.layoutPreview &&
monsterStage?.dataset.active === "true" &&
!monsterDefeatInProgress &&
appearAttackHoldUntil <= Date.now() &&
!fxBusy &&
fxQueue.length === 0
);
}
function runCounterTick() {
if (!counterLoopAllowed()) {
stopCounterLoop(); // 条件が崩れたら止める(キュー枯渇時に pumpFx が再開する)
return;
}
// 対象母集団=勇者 + 在席精霊(life>0)。ランダムに1体。
const livingAllies = (latestAllies || []).filter((ally) => (ally.life ?? 5) > 0);
const targets = [{ kind: "hero" }, ...livingAllies.map((ally) => ({ kind: "ally", allyId: ally.id }))];
const target = targets[Math.floor(Math.random() * targets.length)];
if (target.kind === "ally") {
// 精霊への反撃はサーバー権威:CounterHit を投げ、被弾演出はサーバーの ally_hit/ally_defeated 受信で再生する
// (ローカルでも演出すると二重になるため、ここでは演出しない)。
reportCounterHit(`counter-${(counterSeq += 1)}-${Date.now()}`, target.allyId);
} else {
// 勇者はサーバーに state を持たない=被弾演出をローカルで直接再生(演出のみ)。
playEffect({ type: "monster_counter", target: "hero", synthetic: true, counterEffect: currentCounterEffect() });
}
}
// フロントの反撃ヒットをサーバーへ通知(要件4。サーバーがライフ減算・退場を確定して再 broadcast)。
function reportCounterHit(hitId, allyId) {
try {
fetch("/control/counter-hit", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hitId, allyId }),
keepalive: true
}).catch((error) => console.error("[rpgdev] counter-hit POST failed", error));
} catch (error) {
console.error("[rpgdev] counter-hit failed", error);
}
}
// そのエフェクトのアニメが終わるまでの目安(ms)。0 は即時(アニメ枠を占有せず次へ)。
function fxAnimMs(effect) {
switch (effect.type) {
case "attack":
if (effect.stagger) return 300;
return effect.kind === "skill" ? 560 : 520;
case "counter":
return 420;
case "monster_counter":
case "ally_hit":
return 360; // 被弾リアクション(要件3)
case "ally_defeated":
return 520; // 精霊の被弾退場(要件4)
case "world":
return effect.transition ? 1500 : 0; // 全画面トランジションのみキューを占有(要件5)。通常 world は即時。
case "monster_dying":
return 320;
case "monster_defeated":
return 520;
case "finisher":
return 640; // 会心の一撃(斬撃)を見せ切ってから撃破へ進む
case "ally_return":
return 560; // 撃破後、精霊を1体ずつ順番に帰す(キューを占有して整列退場させる)
case "ally_summon":
return 400; // 精霊召喚も攻撃キューと同様にキュー枠を占有(appear-hold + 1秒間隔の対象)
default:
return 0; // 出現・CLEAR 等はアニメを占有しない(即時)
}
}
function effects(list) {
if (!Array.isArray(list) || !list.length) return;
stopCounterLoop(); // 新バッチ到来=戦況が動く。反撃ループは一旦止め、キュー枯渇時に pumpFx が再開する(要件2)。
if (list.some((effect) => effect.type === "monster_appeared")) {
monsterDefeatInProgress = false;
}
const hasDefeat = list.some((effect) => effect.type === "monster_defeated");
if (hasDefeat) {
clearStaleCombatQueueForDefeat("defeat-received");
}
let defeatQueued = monsterDefeatInProgress || fxQueue.some((effect) => effect.type === "monster_defeated");
for (const effect of list) {
if (defeatQueued && effect.type === "attack") {
// 撃破が確定したバッチ以降の攻撃は再生しない=由来 Hook 付きで「取りこぼし」を記録。
trace({ kind: "drop", tag: effectTag(effect), reason: "defeat-queued", origin: effect.origin });
continue;
}
if (effect.type === "attack") {
const queued = fxQueue.reduce((n, e) => (e.type === "attack" ? n + 1 : n), 0);
if (queued >= MAX_QUEUED_ATTACKS) {
trace({ kind: "drop", tag: effectTag(effect), reason: "max-queued", origin: effect.origin });
continue; // 間引き
}
}
if (effect.type === "monster_defeated") {
// 撃破の前に、まだ再生していない攻撃(トドメに至った一連の攻撃)はそのまま流し、
// その後に会心の一撃(finisher)→撃破とする。攻撃アニメは捨てない=欠落させない。
// ただし撃破がキューに入った後の別バッチ攻撃は、モンスター消滅後に漏れて見えるため受け付けない。
// finisher はフロント合成(synthetic)。由来は撃破を起こした Hook を引き継ぐ。
fxQueue.push({ type: "finisher", synthetic: true, origin: effect.origin });
defeatQueued = true;
fxQueue.push(effect); // 撃破。背景切替はバッチ末尾の world 効果が担う(精霊帰還の後)。
continue;
}
fxQueue.push(effect);
}
pumpFx();
}
function pumpFx() {
if (fxBusy) return;
while (fxQueue.length) {
const effect = fxQueue[0]; // まだ消費しない(保留判定のため覗くだけ)
if (monsterDefeatInProgress && (effect.type === "attack" || effect.type === "ally_summon")) {
fxQueue.shift();
trace({ kind: "drop", tag: effectTag(effect), reason: "defeat-in-progress", origin: effect.origin });
continue;
}
// 出現演出と被らせない:出現開始から APPEAR_ATTACK_DELAY_MS の間は攻撃/召喚キューを再生しない。
if (effect.type === "attack" || effect.type === "ally_summon") {
const wait = appearAttackHoldUntil - Date.now();
if (wait > 0) {
trace({ kind: "hold", tag: effectTag(effect), reason: "appear-hold", wait, origin: effect.origin });
fxBusy = true;
window.setTimeout(() => {
fxBusy = false;
pumpFx();
}, wait);
return; // shift せずに待つ(保留が明けてから同じ攻撃を再生)
}
}
fxQueue.shift();
trace({
kind: "play",
tag: effectTag(effect),
attackKind: effect.kind,
skill: effect.skill,
allyElement: effect.allyElement,
synthetic: effect.synthetic,
origin: effect.origin
});
playEffect(effect);
// 勇者スキル攻撃を再生したら、在席精霊の追撃をフロント生成で直後に割り込ませる(Hook非依存)。
// これで Hook の数で精霊攻撃が多重化せず、画面側で「スキル→精霊が順番に追撃」になる。
if (effect.type === "attack" && effect.kind === "skill" && !effect.synthetic) {
enqueueSpiritFollowup(effect);
}
const anim = fxAnimMs(effect);
if (anim > 0) {
// 攻撃キューは固定 1 秒、その他はアニメ目安 + 0.1 秒待って次へ。
fxBusy = true;
window.setTimeout(() => {
fxBusy = false;
pumpFx();
}, fxQueueDelayMs(effect, anim));
return;
}
// anim === 0 の即時エフェクトは待たずに続けて処理。
}
// キュー枯渇=勇者スキル+全精霊追撃を再生し切った。モンスター生存中なら反撃ループを始める(要件2)。
if (counterLoopAllowed()) startCounterLoop();
}
function fxQueueDelayMs(effect, anim) {
// 勇者攻撃・精霊追撃・精霊召喚は種別を問わず一律 1 秒間隔(前のキュー再生開始から1秒後)。
if (effect.type === "attack" || effect.type === "ally_summon") {
return ATTACK_QUEUE_INTERVAL_MS;
}
return anim + ANIM_GAP;
}
// 勇者スキル攻撃の再生に続けて、在席精霊(latestAllies)の追撃をキュー先頭へ割り込ませる。
// Hook 依存ではなく画面側の演出なので、Hook が何回来ても「スキル1回につき精霊が1巡」だけ。
// 撃破中は追撃しない(撃破演出を優先)。撃破時の defeat-clear で未再生の追撃は破棄される。
function enqueueSpiritFollowup(skillEffect) {
if (monsterDefeatInProgress) return;
if (!Array.isArray(latestAllies) || !latestAllies.length) return;
// キュー上限(MAX_QUEUED_ATTACKS)を超えないぶんだけ積む(スキル連打でも攻撃キューを詰まらせない)。
const queuedAttacks = fxQueue.reduce((n, e) => (e.type === "attack" ? n + 1 : n), 0);
const budget = MAX_QUEUED_ATTACKS - queuedAttacks;
if (budget <= 0) return;
// 要件1: 在席精霊は全員が勇者スキルの後に追撃する。順番はランダム(Fisher-Yates でコピーをシャッフル)。
// latestAllies 本体は破壊しない(表示順・次回追撃に影響させない)。被弾退場した精霊(life<=0)と
// まだ召喚演出が出ていない精霊(awaitingSummon=カード未表示)は除外=ゴースト追撃を生成しない。
const roster = latestAllies.filter((ally) => (ally.life ?? 5) > 0 && !awaitingSummon.has(ally.id));
for (let i = roster.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[roster[i], roster[j]] = [roster[j], roster[i]];
}
const followups = roster.slice(0, budget).map((ally) => ({
type: "attack",
kind: "ally",
synthetic: true,
allyId: ally.id,
allyElement: ally.element,
origin: skillEffect.origin // 由来は親スキルの Hook を引き継ぐ
}));
fxQueue.unshift(...followups); // 親スキルの直後(他の後続より前)に割り込ませる
}
function clearStaleCombatQueueForDefeat(reason = "defeat-clear") {
// 攻撃・finisher・未再生の精霊召喚は撃破時に掃除する(召喚も攻撃キューと同じ扱い)。ally_return は残す。
const stale = (effect) =>
effect.type === "attack" || effect.type === "finisher" || effect.type === "ally_summon";
for (const effect of fxQueue) {
if (stale(effect)) {
trace({ kind: "drop", tag: effectTag(effect), reason, origin: effect.origin });
}
}
fxQueue = fxQueue.filter((effect) => !stale(effect));
appearAttackHoldUntil = 0;
stopCounterLoop(); // 撃破処理中は反撃しない(要件2)
}
function playEffect(effect) {
switch (effect.type) {
case "monster_appeared":
// 出現開始時刻を基準に、以後 4 秒は攻撃キューの再生を保留する(出現演出と被らせない)。
appearAttackHoldUntil = Date.now() + APPEAR_ATTACK_DELAY_MS;
if (effect.monster) {
const sprite = monsterSprite(effect.monster);
lastRenderedMonster = { ...effect.monster, sprite };
setMonsterSprite(sprite);
monsterName.textContent = "";
monsterStage.dataset.active = "true";
}
setMonsterAction("appear", 700);
monsterAppearImpact();
monsterAppearSound();
break;
case "engage":
flash("#ff8a4c");
burst(0.52, 0.44, "#ffb15c", 28);
sting([52, 55, 59]);
break;
case "attack":
if (effect.kind === "ally") {
// 再生時点で精霊が既に居なければ(帰還等で消えた)追撃を出さない=ゴースト攻撃防止。
if (effect.allyId && allies && !allies.querySelector(`[data-ally-id="${effect.allyId}"]`)) break;
pulseAlly(effect.allyId, effect.stagger ? "stagger" : "assist");
const element = allyElement(effect);
spawnMonsterImpact(element);
allyElementImpact(element, effect.stagger);
shakeStage(effect.stagger ? "light" : "hit");
allyAttackSound(element, effect.stagger);
break;
}
if (effect.kind === "skill") {
slash("skill");
shakeStage(effect.stagger ? "light" : "skill");
monsterBurst(effect.stagger ? "#d8c7ff" : "#ffd15c", effect.stagger ? 18 : 34);
monsterBurst("#f0b73a", effect.stagger ? 10 : 18);
showSkillBanner(effect.skill || "SKILL");
heroAttackSound("skill", effect.stagger);
} else {
slash("normal");
shakeStage(effect.stagger ? "light" : "hit");
monsterBurst(effect.stagger ? "#9fb8c8" : "#ffe9a8", effect.stagger ? 12 : 24);
heroAttackSound("normal", effect.stagger);
}
break;
case "counter":
flash("#ff3b3b");
if (effect.monsterId) {
heroHitReaction(counterEffectKind(effect.counterEffect));
} else {
const rect = canvas.getBoundingClientRect();
counterImpact(counterEffectKind(effect.counterEffect), { x: rect.width * 0.34, y: rect.height * 0.54 }, 0.9);
}
sting([45, 40]);
break;
case "monster_counter":
// モンスターの反撃を勇者が食らった(要件3。勇者は state ライフ無し=演出のみ)。
heroHitReaction(counterEffectKind(effect.counterEffect));
flash("#ff5a4d");
shakeStage("hit");
damageSound();
break;
case "ally_hit":
// サーバー確定の精霊被弾(CounterHit→ally_hit)。残ライフは render が反映。被弾演出+音(要件3/4)。
allyHitImpact(effect.allyId, effect.element, effect.allyCenter, counterEffectKind(effect.counterEffect));
flash("#ff5a4d");
damageSound();
break;
case "ally_defeated":
// 精霊が5回被弾して退場(被弾死。撃破時の ally_return とは別演出)(要件4)。
allyHitImpact(effect.allyId, effect.element, effect.allyCenter, counterEffectKind(effect.counterEffect), 1.18);
returnSpiritCard(effect.allyId, effect.element, effect.allyCenter);
flash("#ff5a4d");
damageSound();
break;
case "monster_dying":
flash("#c8a0ff");
break;
case "finisher":
// 勇者の会心の一撃。撃破の直前に必ず1回流す(モンスターはまだ画面に居る)。
// トドメ演出ではスキル名称(技名カットイン)は出さない=視覚演出と効果音のみ。
flash("#fff4c2");
slash("skill");
window.setTimeout(() => slash("skill"), 150); // 二段斬りで会心らしさを出す
shakeStage("skill");
monsterBurst("#ffd15c", 40);
monsterBurst("#fff7dd", 22);
heroAttackSound("finisher");
break;
case "monster_defeated":
monsterDefeatInProgress = true; // 以後(次の出現まで)に届く攻撃アニメは破棄する
clearStaleCombatQueueForDefeat("defeat-play"); // 攻撃/finisher は掃除、ally_return は残す
holdDefeatedMonster();
monsterDefeatImpact();
monsterDefeatSound();
// 背景/BGM 切替は、このバッチ末尾に積まれた world 効果が(撃破→精霊帰還の後に)担う。
break;
case "monster_fled":
burst(0.5, 0.46, "#9aa6b2", 16);
break;
case "retreat":
showToast("後退", "info");
break;
case "turn_completed":
burst(0.5, 0.42, "#7dd873", 70);
showToast("CLEAR", "win");
sting([72, 76, 79, 84, 88]);
break;
case "turn_blocked":
showToast(`未討伐 ${effect.remaining}`, "info");
break;
case "ally_summon":
// 召喚をキューで再生する瞬間に、伏せていたカードを出す=バースト/トーストとカード表示を同時にする。
if (effect.ally?.id) awaitingSummon.delete(effect.ally.id);
renderAllies(latestAllies);
summonBurst();
pulseAlly(effect.ally?.id, "summon");
showToast(`${effect.ally?.name || "仲間"} 召喚`, "ally");
sting([64, 67, 71, 76]);
break;
case "ally_return":
// 撃破演出のあと、精霊を1体ずつ順番に帰す(属性色のエフェクト+効果音つき)。
// 背景切替は末尾の world 効果が担うので、ここでは帰還演出だけ。
returnSpiritCard(effect.allyId, effect.element, effect.allyCenter);
allyReturnSound(effect.element);
break;
case "world":
// 戦闘→探検の遷移は全画面トランジションで被覆し、その最中に背景/勇者/phase を差し替える(要件5)。
// それ以外(通常の world 変化)は即時適用。
if (effect.transition) {
playSceneTransition(effect);
} else {
// 背景/BGM/勇者スプライト/phase をこのタイミングで切り替える(単一キューの順番どおり)。
applyWorld(effect);
if (effect.releaseDefeat) {
worldVisualsHeld = false; // 精霊が全員帰った=撃破時の保留を解除
renderAllies(latestAllies); // 保留していた精霊カードを最終同期(撃破後=空)
}
}
break;
case "compact_pre":
showToast("記憶が霞む…", "info");
break;
case "compact_post":
showToast("霧が晴れた", "info");
break;
case "hold":
showToast("!", "info");
break;
default:
break;
}
}
function showToast(text, kind) {
if (isMonsterTextSuppressed()) return;
if (!toast || !text) return;
const item = document.createElement("div");
item.className = `toast-item toast-${kind || "info"}`;
item.textContent = text;
toast.appendChild(item);
requestAnimationFrame(() => item.classList.add("in"));
window.setTimeout(() => item.classList.add("out"), 1100);
window.setTimeout(() => item.remove(), 1500);
while (toast.children.length > 4) toast.removeChild(toast.firstChild);
}
function showSkillBanner(skill) {
if (!stage) return;
const item = document.createElement("div");
item.className = "skill-cutin";
item.textContent = `${formatSkillName(skill)}!!`;
stage.appendChild(item);
window.setTimeout(() => item.classList.add("out"), 760);
window.setTimeout(() => item.remove(), 980);
}
function isMonsterTextSuppressed() {