refactor(runtime): extract P9.1 foundation boundaries

This commit is contained in:
2026-08-13 12:01:30 +08:00
parent 8c1d7ab6c5
commit c33c3042b1
13 changed files with 4564 additions and 3694 deletions
File diff suppressed because it is too large Load Diff
+213
View File
@@ -0,0 +1,213 @@
class_name P91BattleFixture
extends RefCounted
const FIRST_DURATIONS: Array[float] = [1.60, 1.15, 1.05, 1.05, 1.05, 1.35]
const SECOND_DURATIONS := {
"EQ-002": [1.35, 1.00, 1.10, 1.05, 1.30, 1.25],
"EQ-061": [1.20, 0.85, 0.95, 0.90, 1.00, 1.10],
}
const THIRD_DURATIONS := {
"EQ-002": [1.20, 0.80, 1.00, 1.00, 1.10, 1.10, 1.40],
"EQ-061": [1.10, 0.70, 0.90, 0.90, 0.90, 0.90, 1.20],
"recruit": [1.10, 0.80, 0.90, 0.80, 0.80, 1.10, 1.50],
}
const FOURTH_DURATIONS := {
"R6-01:guarded": [1.10, 0.80, 1.00, 0.90, 1.00, 1.10, 1.50],
"R6-01:tempo": [1.00, 0.70, 0.80, 0.80, 0.80, 0.90, 1.40],
"R6-01:full_line": [1.00, 0.80, 0.90, 0.90, 0.90, 1.00, 1.50],
"R6-02:guarded": [1.20, 1.00, 1.10, 1.00, 1.10, 1.10, 1.50],
"R6-02:tempo": [1.00, 0.60, 0.80, 0.70, 0.70, 0.90, 1.30],
"R6-02:full_line": [1.00, 0.70, 0.90, 0.80, 0.80, 1.00, 1.60],
"R6-03:guarded": [1.10, 0.90, 1.00, 1.00, 1.00, 1.20, 1.60],
"R6-03:tempo": [1.00, 0.70, 0.90, 0.80, 0.80, 1.00, 1.50],
"R6-03:full_line": [1.00, 0.80, 0.90, 0.90, 0.90, 1.00, 1.50],
}
const FIFTH_DURATIONS := {
"normal": [1.10, 0.90, 1.10, 1.00, 1.00, 1.00, 1.30],
"farstring": [0.90, 0.60, 0.90, 0.90, 0.90, 0.90, 1.30],
}
func duration_for_phase(encounter_id: StringName, phase: int, decisions: Dictionary = {}) -> float:
var durations: Array = FIRST_DURATIONS
match encounter_id:
&"battle_2":
durations = SECOND_DURATIONS.get(String(decisions.get("adaptation", "")), FIRST_DURATIONS)
&"battle_3":
var key := "recruit" if not String(decisions.get("added_id", "")).is_empty() else String(decisions.get("retained_item", ""))
durations = THIRD_DURATIONS.get(key, FIRST_DURATIONS)
&"battle_4":
var key := "%s:%s" % [decisions.get("route", ""), decisions.get("profile", "")]
durations = FOURTH_DURATIONS.get(key, FIRST_DURATIONS)
&"battle_5":
durations = FIFTH_DURATIONS.get("farstring" if decisions.get("trained_profession", "") == "Farstring" else "normal", FIRST_DURATIONS)
if phase < 0 or phase >= durations.size():
return 0.0
return float(durations[phase])
func impact_time(encounter_id: StringName, phase: int, decisions: Dictionary = {}) -> float:
if encounter_id == &"battle_5":
match phase:
1: return 2.0 / 7.0 if decisions.get("trained_profession", "") == "Farstring" else -1.0
2, 3: return 4.0 / 12.0
4, 5: return 2.0 / 7.0
6: return 5.0 / 10.0
if encounter_id == &"battle_4":
match phase:
1: return 3.0 / 12.0 if decisions.get("profile", "") == "tempo" and decisions.get("route", "") == "R6-02" else -1.0
2: return 4.0 / 12.0
3, 4: return 2.0 / 7.0
5: return 3.0 / 10.0
6: return 5.0 / 10.0
if encounter_id == &"battle_3":
match phase:
1: return 3.0 / 12.0 if decisions.get("retained_item", "") == "EQ-061" else -1.0
2: return 4.0 / 12.0
3, 4: return 2.0 / 7.0
5: return 3.0 / 10.0
6: return 5.0 / 10.0
if encounter_id == &"battle_2" and decisions.get("adaptation", "") == "EQ-061" and phase == 1:
return 3.0 / 12.0
match phase:
2: return 4.0 / 12.0
3: return 2.0 / 7.0
4: return 3.0 / 10.0
5: return 5.0 / 10.0
return -1.0
func impact_id(encounter_id: StringName, phase: int) -> StringName:
return StringName("%s:phase_%d" % [encounter_id, phase])
func semantic_impact(encounter_id: StringName, phase: int, target: StringName, delta: int, qualifier: StringName, tier: StringName = &"medium") -> Dictionary:
return {
"encounter_id": encounter_id,
"phase": phase,
"impact_id": impact_id(encounter_id, phase),
"target_member_id": target,
"delta": delta,
"qualifier": qualifier,
"tier": tier,
}
func resolve_impact(encounter_id: StringName, phase: int, state: FirstSessionState, context: Dictionary = {}) -> Dictionary:
match encounter_id:
&"battle_1": return _battle_1(phase, state)
&"battle_2": return _battle_2(phase, state)
&"battle_3": return _battle_3(phase, state)
&"battle_4": return _battle_4(phase, state, context.get("profile", ""))
&"battle_5": return _battle_5(phase, state, context.get("trained_slot", ""), context.get("trained_profession", ""))
return {}
func _battle_1(phase: int, state: FirstSessionState) -> Dictionary:
match phase:
2:
var next_hp := 82 if state.front_choice == "Hearthguard" else (88 if state.front_choice == "Wayrunner" else 58)
return _resolved("front_hp", state.front_hp, next_hp, &"front", "格挡" if state.front_choice == "Hearthguard" else ("闪避减伤" if state.front_choice == "Wayrunner" else "脆弱先行"), "blocked" if state.front_choice == "Hearthguard" else ("evaded" if state.front_choice == "Wayrunner" else "exposed lead"), &"hit", &"medium", &"guard")
3:
if state.rear_choice in ["Farstring", "Wayrunner"]:
var next_hp := 64 if state.rear_choice == "Farstring" else 70
return _resolved("enemy_hp", state.enemy_hp, next_hp, &"enemy", "后排命中" if state.rear_choice == "Farstring" else "侧翼反击", "rear hit" if state.rear_choice == "Farstring" else "flank counter", &"role", &"medium", &"rear")
return _resolved("front_hp", state.front_hp, mini(100, state.front_hp + 12), &"front", "治疗", "healing", &"role", &"medium", &"rear")
4: return _resolved("enemy_hp", state.enemy_hp, 34, &"enemy", "连携", "combo", &"hit")
5: return _resolved("enemy_hp", state.enemy_hp, 0, &"enemy", "终结", "finisher", &"victory", &"large")
return {}
func _battle_2(phase: int, state: FirstSessionState) -> Dictionary:
match phase:
1:
return _resolved("enemy_hp", state.enemy_hp, 72, &"enemy", "先手", "initiative", &"hit") if state.adaptation_choice == "EQ-061" else {}
2:
return _resolved("front_hp", state.front_hp, 90 if state.adaptation_choice == "EQ-002" else 70, &"front", "抗推" if state.adaptation_choice == "EQ-002" else "重击", "push resisted" if state.adaptation_choice == "EQ-002" else "heavy strike", &"hit", &"medium", &"guard")
3:
if state.rear_choice in ["Farstring", "Wayrunner"]:
var next_hp := (64 if state.adaptation_choice == "EQ-002" else 36) if state.rear_choice == "Farstring" else (70 if state.adaptation_choice == "EQ-002" else 42)
return _resolved("enemy_hp", state.enemy_hp, next_hp, &"enemy", "后排命中" if state.rear_choice == "Farstring" else "侧翼反击", "rear hit" if state.rear_choice == "Farstring" else "flank counter", &"role", &"medium", &"rear")
return _resolved("front_hp", state.front_hp, mini(100, state.front_hp + 12), &"front", "治疗", "healing", &"role", &"medium", &"rear")
4: return _resolved("enemy_hp", state.enemy_hp, 34 if state.adaptation_choice == "EQ-002" else 24, &"enemy", "连携", "combo", &"hit")
5: return _resolved("enemy_hp", state.enemy_hp, 0, &"enemy", "终结", "finisher", &"victory", &"large")
return {}
func _battle_3(phase: int, state: FirstSessionState) -> Dictionary:
match phase:
1:
return _resolved("enemy_hp", state.enemy_hp, 102, &"enemy", "晨钟先手", "Early Bell", &"hit") if state.retained_item == "EQ-061" else {}
2:
return _resolved("front_hp", state.front_hp, 90 if state.retained_item == "EQ-002" else 70, &"front", "装备抗推" if state.retained_item == "EQ-002" else "队长重击", "item resisted" if state.retained_item == "EQ-002" else "captain strike", &"hit", &"medium", &"guard")
3:
if state.rear_choice == "Farstring":
return _resolved("enemy_hp", state.enemy_hp, 66 if state.added_rear.is_empty() else 94, &"enemy", "弓手命中", "Archer hit", &"role", &"medium", &"rear")
return _resolved("front_hp", state.front_hp, mini(100, state.front_hp + 12), &"front", "治疗", "healing", &"role", &"medium", &"rear")
4:
if not state.added_rear.is_empty():
if state.added_rear == "Farstring":
return _resolved("enemy_hp", state.enemy_hp, 94, &"enemy", "新弓手命中", "new Archer hit", &"role", &"medium", &"rear")
return _resolved("front_hp", state.front_hp, mini(100, state.front_hp + 12), &"front", "新治疗支援", "new Healer support", &"role", &"medium", &"rear")
return _resolved("enemy_hp", state.enemy_hp, 44, &"enemy", "装备连携", "item follow-up", &"hit")
5: return _resolved("enemy_hp", state.enemy_hp, 32 if not state.added_rear.is_empty() else 26, &"enemy", "三人连携" if not state.added_rear.is_empty() else "压制", "three-person combo" if not state.added_rear.is_empty() else "pressure", &"hit")
6: return _resolved("enemy_hp", state.enemy_hp, 0, &"enemy", "队长击破", "captain defeated", &"victory", &"large")
return {}
func _battle_4(phase: int, state: FirstSessionState, profile: String) -> Dictionary:
match phase:
1:
return _resolved("enemy_hp", state.enemy_hp, 90, &"enemy", "晨钟抢速", "tempo counter", &"hit") if state.route_choice == "R6-02" and profile == "tempo" else {}
2:
if state.route_choice == "R6-03":
return _resolved("rear_hp", state.rear_hp, 65, &"rear", "后排分击", "rear split", &"hit")
var next_hp := (90 if profile == "guarded" else (60 if profile == "tempo" else 70)) if state.route_choice == "R6-01" else 70
return _resolved("front_hp", state.front_hp, next_hp, &"front", "胫甲抗碎" if state.route_choice == "R6-01" and profile == "guarded" else ("速度爆发" if state.route_choice == "R6-02" else "正面粉碎"), "brace answered" if state.route_choice == "R6-01" and profile == "guarded" else ("tempo burst" if state.route_choice == "R6-02" else "front crush"), &"hit")
3:
if state.rear_choice == "Farstring":
return _resolved("enemy_hp", state.enemy_hp, mini(state.enemy_hp, 72), &"enemy", "弓手命中", "Archer hit", &"role", &"medium", &"rear")
if state.route_choice == "R6-03":
return _resolved("rear_hp", state.rear_hp, 88 if profile == "full_line" else mini(100, state.rear_hp + 12), &"rear", "后排自救", "rear recovery", &"role", &"medium", &"rear")
return _resolved("front_hp", state.front_hp, mini(100, state.front_hp + 12), &"front", "治疗", "healing", &"role", &"medium", &"rear")
4:
if not state.added_rear.is_empty():
if state.added_rear == "Farstring":
return _resolved("enemy_hp", state.enemy_hp, mini(state.enemy_hp, 72), &"enemy", "新弓手命中", "new Archer hit", &"role", &"medium", &"rear")
if state.route_choice == "R6-03":
return _resolved("rear_hp", state.rear_hp, 88, &"rear", "双后排恢复", "rear-line recovery", &"role", &"medium", &"rear")
return _resolved("front_hp", state.front_hp, mini(100, state.front_hp + 12), &"front", "新治疗支援", "new Healer support", &"role", &"medium", &"rear")
return _resolved("enemy_hp", state.enemy_hp, mini(state.enemy_hp, 44), &"enemy", "构筑反击", "build counter", &"hit")
5: return _resolved("enemy_hp", state.enemy_hp, 28, &"enemy", "路线突破", "route break", &"hit")
6: return _resolved("enemy_hp", state.enemy_hp, 0, &"enemy", "路线守卫击破", "warden defeated", &"victory", &"large")
return {}
func _battle_5(phase: int, state: FirstSessionState, trained_slot: String, trained_profession: String) -> Dictionary:
match phase:
1:
return _resolved("enemy_hp", state.enemy_hp, 110, &"enemy", "历练先射", "trained early shot", &"hit", &"medium", &"rear") if trained_profession == "Farstring" else {}
2:
var damage := 30 if state.retained_item == "EQ-002" else 45
if trained_slot == "front": damage = maxi(0, damage - 20)
return _resolved("front_hp", state.front_hp, maxi(0, state.front_hp - damage), &"front", "历练减伤" if trained_slot == "front" else ("胫甲抗压" if state.retained_item == "EQ-002" else "正面重压"), "trained mitigation" if trained_slot == "front" else ("Frontpin braced" if state.retained_item == "EQ-002" else "front pressure"), &"hit", &"medium", &"guard")
3: return _resolved("rear_hp", state.rear_hp, 60, &"rear", "后排分压", "rear split", &"hit")
4:
if trained_slot == "rear" and state.rear_choice == "Farstring": return {}
if state.rear_choice == "Farstring":
return _resolved("enemy_hp", state.enemy_hp, maxi(0, state.enemy_hp - 30), &"enemy", "原弓手命中", "original Archer", &"role", &"medium", &"rear")
var healing := 25 if trained_slot == "rear" else 12
return _resolved("rear_hp", state.rear_hp, mini(100, state.rear_hp + healing), &"rear", "历练强疗" if trained_slot == "rear" else "后排治疗", "trained recovery" if trained_slot == "rear" else "rear healing", &"role", &"medium", &"rear")
5:
if not state.added_rear.is_empty():
if trained_slot == "added" and state.added_rear == "Farstring": return {}
if state.added_rear == "Farstring":
return _resolved("enemy_hp", state.enemy_hp, maxi(0, state.enemy_hp - 30), &"enemy", "增援弓手命中", "added Archer", &"role", &"medium", &"rear")
var healing := 25 if trained_slot == "added" else 12
return _resolved("rear_hp", state.rear_hp, mini(100, state.rear_hp + healing), &"rear", "历练强疗" if trained_slot == "added" else "增援治疗", "trained recovery" if trained_slot == "added" else "added healing", &"role", &"medium", &"rear")
return _resolved("enemy_hp", state.enemy_hp, maxi(0, state.enemy_hp - 40), &"enemy", "保留构筑追击", "retained build", &"hit")
6: return _resolved("enemy_hp", state.enemy_hp, 0, &"enemy", "双压守卫击破", "Twin Warden defeated", &"victory", &"large")
return {}
func _resolved(field: StringName, previous: int, value: int, target: StringName, qualifier_zh: String, qualifier_en: String, cue: StringName, tier: StringName = &"medium", counter: StringName = &"") -> Dictionary:
return {
"patch": {field: value},
"target": target,
"delta": value - previous,
"qualifier": StringName(qualifier_en.to_snake_case()),
"qualifier_zh": qualifier_zh,
"qualifier_en": qualifier_en,
"cue": cue,
"tier": tier,
"counter": counter,
}
+103
View File
@@ -0,0 +1,103 @@
class_name P91Contracts
extends RefCounted
const SNAPSHOT_SCHEMA := 1
const SCREEN_BOOT: StringName = &"boot"
const SCREEN_TITLE: StringName = &"title"
const SCREEN_MAIN_MENU: StringName = &"main_menu"
const SCREEN_GAME_INTRO: StringName = &"game_intro"
const SCREEN_SETTINGS: StringName = &"settings"
const SCREEN_FIRST_MISSION: StringName = &"first_mission"
const SCREEN_RECRUIT_SELECTION: StringName = &"recruit_selection"
const SCREEN_FORMATION_REVIEW: StringName = &"formation_review"
const SCREEN_YARD: StringName = &"yard"
const SCREEN_BATTLE_1: StringName = &"battle_1"
const SCREEN_RESULT_1: StringName = &"result_1"
const SCREEN_ADAPTATION: StringName = &"adaptation"
const SCREEN_BATTLE_2: StringName = &"battle_2"
const SCREEN_RESULT_2: StringName = &"result_2"
const SCREEN_GUILD_RESPONSE: StringName = &"guild_response"
const SCREEN_BATTLE_3: StringName = &"battle_3"
const SCREEN_RESULT_3: StringName = &"result_3"
const SCREEN_ROUTE_BOARD: StringName = &"route_board"
const SCREEN_ROUTE_REVIEW: StringName = &"route_review"
const SCREEN_BATTLE_4: StringName = &"battle_4"
const SCREEN_RESULT_4: StringName = &"result_4"
const SCREEN_GROWTH: StringName = &"growth"
const SCREEN_GROWTH_REVIEW: StringName = &"growth_review"
const SCREEN_BATTLE_5: StringName = &"battle_5"
const SCREEN_SESSION_RESULT: StringName = &"session_result"
const OK: StringName = &"ok"
const WRONG_SCREEN: StringName = &"wrong_screen"
const INVALID_ID: StringName = &"invalid_id"
const SELECTION_REQUIRED: StringName = &"selection_required"
const INSUFFICIENT_COIN: StringName = &"insufficient_coin"
const ALREADY_COMMITTED: StringName = &"already_committed"
const STALE_ENCOUNTER: StringName = &"stale_encounter"
const EVENT_SCREEN_CHANGED: StringName = &"screen_changed"
const EVENT_SELECTION_CHANGED: StringName = &"selection_changed"
const EVENT_TRANSACTION_COMMITTED: StringName = &"transaction_committed"
const EVENT_IMPACT_COMMITTED: StringName = &"impact_committed"
const EVENT_ENCOUNTER_FINISHED: StringName = &"encounter_finished"
const EVENT_REWARD_COMMITTED: StringName = &"reward_committed"
const EVENT_RUN_CLEARED: StringName = &"run_cleared"
const ENCOUNTER_1: StringName = &"battle_1"
const ENCOUNTER_2: StringName = &"battle_2"
const ENCOUNTER_3: StringName = &"battle_3"
const ENCOUNTER_4: StringName = &"battle_4"
const ENCOUNTER_5: StringName = &"battle_5"
const AUTO_FORMATION_ORDER: Array[String] = ["Hearthguard", "Wayrunner", "Farstring", "Kindhand"]
const MEMBER_IDS: Array[String] = ["Hearthguard", "Wayrunner", "Farstring", "Kindhand"]
const ADAPTATION_IDS: Array[String] = ["EQ-002", "EQ-061"]
const GUILD_IDS: Array[String] = ["retain_item", "recruit_rear"]
const ROUTE_IDS: Array[String] = ["R6-01", "R6-02", "R6-03"]
const LEGACY_SCREEN_BY_PAGE := {
0: SCREEN_BOOT,
1: SCREEN_TITLE,
2: SCREEN_MAIN_MENU,
3: SCREEN_GAME_INTRO,
4: SCREEN_SETTINGS,
5: SCREEN_FIRST_MISSION,
6: SCREEN_RECRUIT_SELECTION,
9: SCREEN_FORMATION_REVIEW,
10: SCREEN_YARD,
11: SCREEN_BATTLE_1,
12: SCREEN_RESULT_1,
13: SCREEN_ADAPTATION,
14: SCREEN_BATTLE_2,
15: SCREEN_RESULT_2,
16: SCREEN_GUILD_RESPONSE,
17: SCREEN_BATTLE_3,
18: SCREEN_RESULT_3,
19: SCREEN_ROUTE_BOARD,
20: SCREEN_ROUTE_REVIEW,
21: SCREEN_BATTLE_4,
22: SCREEN_RESULT_4,
23: SCREEN_GROWTH,
24: SCREEN_GROWTH_REVIEW,
25: SCREEN_BATTLE_5,
26: SCREEN_SESSION_RESULT,
}
const LEGACY_PAGE_BY_SCREEN := {
SCREEN_BOOT: 0, SCREEN_TITLE: 1, SCREEN_MAIN_MENU: 2,
SCREEN_GAME_INTRO: 3, SCREEN_SETTINGS: 4, SCREEN_FIRST_MISSION: 5,
SCREEN_RECRUIT_SELECTION: 6, SCREEN_FORMATION_REVIEW: 9, SCREEN_YARD: 10,
SCREEN_BATTLE_1: 11, SCREEN_RESULT_1: 12, SCREEN_ADAPTATION: 13,
SCREEN_BATTLE_2: 14, SCREEN_RESULT_2: 15, SCREEN_GUILD_RESPONSE: 16,
SCREEN_BATTLE_3: 17, SCREEN_RESULT_3: 18, SCREEN_ROUTE_BOARD: 19,
SCREEN_ROUTE_REVIEW: 20, SCREEN_BATTLE_4: 21, SCREEN_RESULT_4: 22,
SCREEN_GROWTH: 23, SCREEN_GROWTH_REVIEW: 24, SCREEN_BATTLE_5: 25,
SCREEN_SESSION_RESULT: 26,
}
static func screen_for_legacy_page(page: int) -> StringName:
return LEGACY_SCREEN_BY_PAGE.get(page, &"legacy_unsupported")
static func legacy_page_for_screen(screen: StringName) -> int:
return LEGACY_PAGE_BY_SCREEN.get(screen, -1)
+396
View File
@@ -0,0 +1,396 @@
class_name FirstSessionService
extends RefCounted
const Contracts = preload("res://domain/p9_1/contracts.gd")
const State = preload("res://domain/p9_1/session_state.gd")
const BattleFixture = preload("res://domain/p9_1/battle_fixture.gd")
var state: FirstSessionState
var battle_fixture := BattleFixture.new()
func _init(initial_state: FirstSessionState = null) -> void:
state = initial_state if initial_state != null else State.new()
func reset_transient(reason: StringName = &"reset") -> Dictionary:
state.reset_transient()
return _success([{ "kind": Contracts.EVENT_RUN_CLEARED, "reason": reason }], false)
func open_new_session() -> Dictionary:
state.reset_transient()
return _change_screen(Contracts.SCREEN_FIRST_MISSION, &"open_new_session")
func toggle_recruit(profession_id: String) -> Dictionary:
if state.screen != Contracts.SCREEN_RECRUIT_SELECTION:
return _reject(Contracts.WRONG_SCREEN)
if profession_id not in Contracts.MEMBER_IDS:
return _reject(Contracts.INVALID_ID)
if state.selected_recruits.has(profession_id):
state.selected_recruits.erase(profession_id)
elif state.selected_recruits.size() < 2:
state.selected_recruits.append(profession_id)
else:
return _reject(Contracts.ALREADY_COMMITTED)
return _success([_selection_event(&"recruit", profession_id)])
func review_formation() -> Dictionary:
if state.screen != Contracts.SCREEN_RECRUIT_SELECTION:
return _reject(Contracts.WRONG_SCREEN)
if state.selected_recruits.size() != 2:
return _reject(Contracts.SELECTION_REQUIRED)
var ordered := ordered_selected_recruits()
state.front_choice = ordered[0]
state.rear_choice = ordered[1]
return _change_screen(Contracts.SCREEN_FORMATION_REVIEW, &"review_formation")
func revise_formation() -> Dictionary:
if state.screen != Contracts.SCREEN_FORMATION_REVIEW or state.formation_commit_count > 0:
return _reject(Contracts.WRONG_SCREEN)
return _change_screen(Contracts.SCREEN_RECRUIT_SELECTION, &"revise_formation")
func commit_formation() -> Dictionary:
if state.screen != Contracts.SCREEN_FORMATION_REVIEW:
return _reject(Contracts.WRONG_SCREEN)
if state.formation_commit_count > 0:
return _reject(Contracts.ALREADY_COMMITTED)
if state.selected_recruits.size() != 2 or state.front_choice not in state.selected_recruits or state.rear_choice not in state.selected_recruits:
return _reject(Contracts.SELECTION_REQUIRED)
state.formation_commit_count = 1
state.battle_phase = 0
state.battle_playing = true
state.front_hp = 100
state.enemy_hp = 100
state.phase_impact_committed.clear()
return _change_screen(Contracts.SCREEN_BATTLE_1, &"commit_formation", [{
"kind": Contracts.EVENT_TRANSACTION_COMMITTED,
"transaction": &"formation",
"decision_id": StringName(state.front_choice + ":" + state.rear_choice),
}])
func begin_compat_first_battle() -> Dictionary:
if state.front_choice.is_empty() or state.rear_choice.is_empty():
return _reject(Contracts.SELECTION_REQUIRED)
state.battle_phase = 0
state.battle_playing = true
state.front_hp = 100
state.enemy_hp = 100
state.guard_events = 0
state.rear_events = 0
state.victory_events = 0
state.phase_impact_committed.clear()
state.set_screen(Contracts.SCREEN_BATTLE_1)
return _success([{"kind": Contracts.EVENT_SCREEN_CHANGED, "to": Contracts.SCREEN_BATTLE_1, "cause": &"compat_first_battle"}])
func open_adaptation() -> Dictionary:
if state.screen != Contracts.SCREEN_RESULT_1 or not state.first_reward_committed:
return _reject(Contracts.WRONG_SCREEN)
return _change_screen(Contracts.SCREEN_ADAPTATION, &"open_adaptation")
func open_guild_response() -> Dictionary:
if state.screen != Contracts.SCREEN_RESULT_2 or not state.adaptation_committed:
return _reject(Contracts.WRONG_SCREEN)
state.guild_choice = ""
state.guild_committed = false
state.retained_item = ""
state.added_rear = ""
return _change_screen(Contracts.SCREEN_GUILD_RESPONSE, &"open_guild_response")
func open_route_board() -> Dictionary:
if state.screen != Contracts.SCREEN_RESULT_3 or not state.guild_committed:
return _reject(Contracts.WRONG_SCREEN)
state.route_choice = ""
state.route_committed = false
state.route_reward_committed = false
return _change_screen(Contracts.SCREEN_ROUTE_BOARD, &"open_route_board")
func open_growth() -> Dictionary:
if state.screen != Contracts.SCREEN_RESULT_4 or not state.route_committed or not state.route_reward_committed or state.training_committed:
return _reject(Contracts.WRONG_SCREEN)
state.training_choice = ""
state.growth_member_id = ""
state.trained_member = ""
state.trained_member_id = ""
return _change_screen(Contracts.SCREEN_GROWTH, &"open_growth")
func commit_reward(reward_id: StringName, coin_delta: int, required_screen: StringName) -> Dictionary:
if state.screen != required_screen:
return _reject(Contracts.WRONG_SCREEN)
match reward_id:
&"battle_1":
if state.first_reward_committed: return _reject(Contracts.ALREADY_COMMITTED)
state.first_reward_committed = true
&"route":
if state.route_reward_committed or not state.route_committed: return _reject(Contracts.ALREADY_COMMITTED)
state.route_reward_committed = true
&"battle_5":
if state.fifth_reward_committed or not state.training_committed or state.enemy_hp != 0: return _reject(Contracts.ALREADY_COMMITTED)
state.fifth_reward_committed = true
_:
return _reject(Contracts.INVALID_ID)
state.coin += coin_delta
return _success([{"kind": Contracts.EVENT_REWARD_COMMITTED, "reward_id": reward_id, "coin_delta": coin_delta, "coin_after": state.coin}])
func choose_adaptation(item_id: String) -> Dictionary:
if state.screen != Contracts.SCREEN_ADAPTATION:
return _reject(Contracts.WRONG_SCREEN)
if state.adaptation_committed:
return _reject(Contracts.ALREADY_COMMITTED)
if item_id not in Contracts.ADAPTATION_IDS:
return _reject(Contracts.INVALID_ID)
state.adaptation_choice = item_id
return _success([_selection_event(&"adaptation", item_id)])
func commit_adaptation() -> Dictionary:
if state.screen != Contracts.SCREEN_ADAPTATION:
return _reject(Contracts.WRONG_SCREEN)
if state.adaptation_committed:
return _reject(Contracts.ALREADY_COMMITTED)
if state.adaptation_choice not in Contracts.ADAPTATION_IDS:
return _reject(Contracts.SELECTION_REQUIRED)
state.adaptation_committed = true
state.battle_phase = 0
state.battle_playing = true
state.front_hp = 100
state.enemy_hp = 100
state.phase_impact_committed.clear()
return _change_screen(Contracts.SCREEN_BATTLE_2, &"commit_adaptation", [_transaction_event(&"adaptation", state.adaptation_choice)])
func choose_guild_response(response_id: String) -> Dictionary:
if state.screen != Contracts.SCREEN_GUILD_RESPONSE:
return _reject(Contracts.WRONG_SCREEN)
if state.guild_committed:
return _reject(Contracts.ALREADY_COMMITTED)
if response_id not in Contracts.GUILD_IDS:
return _reject(Contracts.INVALID_ID)
var cost := 12 if response_id == "retain_item" else 14
if state.coin < cost:
return _reject(Contracts.INSUFFICIENT_COIN)
state.guild_choice = response_id
return _success([_selection_event(&"guild_response", response_id)])
func commit_guild_response() -> Dictionary:
if state.screen != Contracts.SCREEN_GUILD_RESPONSE:
return _reject(Contracts.WRONG_SCREEN)
if state.guild_committed:
return _reject(Contracts.ALREADY_COMMITTED)
if state.guild_choice not in Contracts.GUILD_IDS:
return _reject(Contracts.SELECTION_REQUIRED)
var cost := 12 if state.guild_choice == "retain_item" else 14
if state.coin < cost:
return _reject(Contracts.INSUFFICIENT_COIN)
state.guild_committed = true
state.coin -= cost
if state.guild_choice == "retain_item":
state.retained_item = state.adaptation_choice
state.added_rear = ""
else:
state.retained_item = ""
state.added_rear = "Kindhand" if state.rear_choice == "Farstring" else "Farstring"
state.battle_phase = 0
state.battle_playing = true
state.front_hp = 100
state.enemy_hp = 130
state.phase_impact_committed.clear()
return _change_screen(Contracts.SCREEN_BATTLE_3, &"commit_guild_response", [_transaction_event(&"guild_response", state.guild_choice, -cost)])
func choose_route(route_id: String) -> Dictionary:
if state.screen != Contracts.SCREEN_ROUTE_BOARD:
return _reject(Contracts.WRONG_SCREEN)
if state.route_committed:
return _reject(Contracts.ALREADY_COMMITTED)
if route_id not in Contracts.ROUTE_IDS:
return _reject(Contracts.INVALID_ID)
state.route_choice = route_id
return _success([_selection_event(&"route", route_id)])
func review_route() -> Dictionary:
if state.screen != Contracts.SCREEN_ROUTE_BOARD:
return _reject(Contracts.WRONG_SCREEN)
if state.route_choice not in Contracts.ROUTE_IDS:
return _reject(Contracts.SELECTION_REQUIRED)
return _change_screen(Contracts.SCREEN_ROUTE_REVIEW, &"review_route")
func cancel_route_review() -> Dictionary:
if state.screen != Contracts.SCREEN_ROUTE_REVIEW or state.route_committed:
return _reject(Contracts.WRONG_SCREEN)
return _change_screen(Contracts.SCREEN_ROUTE_BOARD, &"cancel_route_review")
func commit_route() -> Dictionary:
if state.screen != Contracts.SCREEN_ROUTE_REVIEW:
return _reject(Contracts.WRONG_SCREEN)
if state.route_committed:
return _reject(Contracts.ALREADY_COMMITTED)
if state.route_choice not in Contracts.ROUTE_IDS:
return _reject(Contracts.SELECTION_REQUIRED)
state.route_committed = true
state.battle_phase = 0
state.battle_playing = true
state.front_hp = 100
state.rear_hp = 100
state.enemy_hp = 120
state.phase_impact_committed.clear()
return _change_screen(Contracts.SCREEN_BATTLE_4, &"commit_route", [_transaction_event(&"route", state.route_choice)])
func choose_growth(member_id: String) -> Dictionary:
if state.screen != Contracts.SCREEN_GROWTH:
return _reject(Contracts.WRONG_SCREEN)
if state.training_committed:
return _reject(Contracts.ALREADY_COMMITTED)
if member_id not in present_member_ids():
return _reject(Contracts.INVALID_ID)
state.growth_member_id = member_id
return _success([_selection_event(&"growth", member_id)])
func review_growth() -> Dictionary:
if state.screen != Contracts.SCREEN_GROWTH:
return _reject(Contracts.WRONG_SCREEN)
if state.growth_member_id not in present_member_ids():
return _reject(Contracts.SELECTION_REQUIRED)
return _change_screen(Contracts.SCREEN_GROWTH_REVIEW, &"review_growth")
func cancel_growth_review() -> Dictionary:
if state.screen != Contracts.SCREEN_GROWTH_REVIEW or state.training_committed:
return _reject(Contracts.WRONG_SCREEN)
return _change_screen(Contracts.SCREEN_GROWTH, &"cancel_growth_review")
func commit_growth() -> Dictionary:
if state.screen != Contracts.SCREEN_GROWTH_REVIEW:
return _reject(Contracts.WRONG_SCREEN)
if state.training_committed:
return _reject(Contracts.ALREADY_COMMITTED)
if state.growth_member_id not in present_member_ids():
return _reject(Contracts.SELECTION_REQUIRED)
state.training_committed = true
state.trained_member_id = state.growth_member_id
state.battle_phase = 0
state.battle_playing = true
state.front_hp = 100
state.rear_hp = 100
state.enemy_hp = 140
state.phase_impact_committed.clear()
return _change_screen(Contracts.SCREEN_BATTLE_5, &"commit_growth", [_transaction_event(&"growth", state.trained_member_id)])
func commit_impact(encounter_id: StringName, phase: int, impact_id: StringName, event: Dictionary) -> Dictionary:
if state.encounter_id() != encounter_id or not state.battle_playing or state.battle_phase != phase:
return _reject(Contracts.STALE_ENCOUNTER)
var key := "%s:%d:%s" % [encounter_id, phase, impact_id]
if state.phase_impact_committed.has(key):
return _reject(Contracts.ALREADY_COMMITTED)
state.phase_impact_committed[key] = true
var committed := event.duplicate(true)
committed["kind"] = Contracts.EVENT_IMPACT_COMMITTED
committed["encounter_id"] = encounter_id
committed["phase"] = phase
committed["impact_id"] = impact_id
state.facts.append(committed.duplicate(true))
return _success([committed])
func resolve_impact(encounter_id: StringName, phase: int, context: Dictionary = {}) -> Dictionary:
return _resolve_impact(encounter_id, phase, context, true)
func resolve_compat_impact(encounter_id: StringName, phase: int, context: Dictionary = {}) -> Dictionary:
return _resolve_impact(encounter_id, phase, context, false)
func _resolve_impact(encounter_id: StringName, phase: int, context: Dictionary, require_playing: bool) -> Dictionary:
if state.encounter_id() != encounter_id or (require_playing and not state.battle_playing) or state.battle_phase != phase:
return _reject(Contracts.STALE_ENCOUNTER)
if state.phase_impact_committed.get(phase, false):
return _reject(Contracts.ALREADY_COMMITTED)
var resolved := battle_fixture.resolve_impact(encounter_id, phase, state, context)
if resolved.is_empty():
return _reject(Contracts.INVALID_ID)
for field in resolved["patch"]:
state.set(field, resolved["patch"][field])
match resolved.get("counter", &""):
&"guard": state.guard_events += 1
&"rear": state.rear_events += 1
state.phase_impact_committed[phase] = true
var event := {
"kind": Contracts.EVENT_IMPACT_COMMITTED,
"encounter_id": encounter_id,
"phase": phase,
"impact_id": battle_fixture.impact_id(encounter_id, phase),
"target_member_id": resolved["target"],
"delta": resolved["delta"],
"qualifier": resolved["qualifier"],
"qualifier_zh": resolved["qualifier_zh"],
"qualifier_en": resolved["qualifier_en"],
"tier": resolved["tier"],
"cue": resolved["cue"],
}
state.facts.append(event.duplicate(true))
return _success([event])
func complete_phase(encounter_id: StringName, phase: int) -> Dictionary:
if state.encounter_id() != encounter_id or not state.battle_playing or state.battle_phase != phase:
return _reject(Contracts.STALE_ENCOUNTER)
state.battle_phase += 1
var finished := (encounter_id in [Contracts.ENCOUNTER_1, Contracts.ENCOUNTER_2] and state.battle_phase == 6) or (encounter_id in [Contracts.ENCOUNTER_3, Contracts.ENCOUNTER_4, Contracts.ENCOUNTER_5] and state.battle_phase == 7)
if not finished:
return _success([])
state.battle_playing = false
state.victory_events += 1
var events: Array = []
match encounter_id:
Contracts.ENCOUNTER_1:
if not state.first_reward_committed:
state.first_reward_committed = true
state.coin += 20
events.append({"kind": Contracts.EVENT_REWARD_COMMITTED, "reward_id": &"battle_1", "coin_delta": 20, "coin_after": state.coin})
state.set_screen(Contracts.SCREEN_RESULT_1)
Contracts.ENCOUNTER_2:
state.set_screen(Contracts.SCREEN_RESULT_2)
Contracts.ENCOUNTER_3:
state.set_screen(Contracts.SCREEN_RESULT_3)
Contracts.ENCOUNTER_4:
if not state.route_reward_committed:
var reward: int = {"R6-01": 4, "R6-02": 7, "R6-03": 10}.get(state.route_choice, 0)
state.route_reward_committed = true
state.coin += reward
events.append({"kind": Contracts.EVENT_REWARD_COMMITTED, "reward_id": &"route", "coin_delta": reward, "coin_after": state.coin})
state.set_screen(Contracts.SCREEN_RESULT_4)
Contracts.ENCOUNTER_5:
if not state.fifth_reward_committed and state.training_committed and state.enemy_hp == 0:
state.fifth_reward_committed = true
state.coin += 8
events.append({"kind": Contracts.EVENT_REWARD_COMMITTED, "reward_id": &"battle_5", "coin_delta": 8, "coin_after": state.coin})
state.set_screen(Contracts.SCREEN_SESSION_RESULT)
events.append({"kind": Contracts.EVENT_ENCOUNTER_FINISHED, "encounter_id": encounter_id, "front_hp": state.front_hp, "rear_hp": state.rear_hp, "enemy_hp": state.enemy_hp})
return _success(events)
func ordered_selected_recruits() -> Array[String]:
var ordered: Array[String] = []
for profession_id in Contracts.AUTO_FORMATION_ORDER:
if state.selected_recruits.has(profession_id):
ordered.append(profession_id)
return ordered
func present_member_ids() -> Array[String]:
var result: Array[String] = [state.front_choice, state.rear_choice]
if not state.added_rear.is_empty():
result.append(state.added_rear)
return result
func snapshot() -> Dictionary:
return state.snapshot()
func _change_screen(to_screen: StringName, cause: StringName, preceding_events: Array = []) -> Dictionary:
var from := state.screen
state.set_screen(to_screen)
var events: Array = preceding_events.duplicate(true)
events.append({"kind": Contracts.EVENT_SCREEN_CHANGED, "from": from, "to": to_screen, "cause": cause})
return _success(events)
func _success(events: Array, increment_revision := true) -> Dictionary:
if increment_revision:
state.revision += 1
assert(state.invariants_hold())
return {"ok": true, "code": Contracts.OK, "events": events.duplicate(true), "snapshot": state.snapshot()}
func _reject(code: StringName) -> Dictionary:
return {"ok": false, "code": code, "events": [], "snapshot": state.snapshot()}
func _selection_event(decision: StringName, selected_id: String) -> Dictionary:
return {"kind": Contracts.EVENT_SELECTION_CHANGED, "decision": decision, "selected_id": StringName(selected_id)}
func _transaction_event(transaction: StringName, decision_id: String, coin_delta := 0) -> Dictionary:
return {"kind": Contracts.EVENT_TRANSACTION_COMMITTED, "transaction": transaction, "decision_id": StringName(decision_id), "coin_delta": coin_delta, "coin_after": state.coin}
+201
View File
@@ -0,0 +1,201 @@
class_name FirstSessionState
extends RefCounted
const Contracts = preload("res://domain/p9_1/contracts.gd")
var revision := 0
var legacy_page := 0
var screen: StringName = Contracts.SCREEN_BOOT
var front_choice := ""
var rear_choice := ""
var selected_recruits: Array[String] = []
var formation_commit_count := 0
var notice := ""
var restart_confirmation := false
var battle_phase := 0
var battle_playing := false
var front_hp := 100
var rear_hp := 100
var enemy_hp := 100
var guard_events := 0
var rear_events := 0
var victory_events := 0
var phase_impact_committed: Dictionary = {}
var coin := 0
var first_reward_committed := false
var adaptation_choice := ""
var adaptation_committed := false
var guild_choice := ""
var guild_committed := false
var retained_item := ""
var added_rear := ""
var route_choice := ""
var route_committed := false
var route_reward_committed := false
var training_choice := ""
var growth_member_id := ""
var training_committed := false
var trained_member := ""
var trained_member_id := ""
var fifth_reward_committed := false
var facts: Array[Dictionary] = []
func set_legacy_page(value: int) -> void:
legacy_page = value
screen = Contracts.screen_for_legacy_page(value)
func compat_patch(fields: Dictionary) -> bool:
for field in fields:
if field not in _compat_patch_fields():
return false
for field in fields:
set(field, fields[field])
return invariants_hold()
func _compat_patch_fields() -> Array[StringName]:
return [
&"legacy_page", &"screen", &"front_choice", &"rear_choice", &"selected_recruits",
&"formation_commit_count", &"notice", &"restart_confirmation", &"battle_phase",
&"battle_playing", &"front_hp", &"rear_hp", &"enemy_hp", &"guard_events",
&"rear_events", &"victory_events", &"phase_impact_committed", &"coin",
&"first_reward_committed", &"adaptation_choice", &"adaptation_committed",
&"guild_choice", &"guild_committed", &"retained_item", &"added_rear",
&"route_choice", &"route_committed", &"route_reward_committed", &"training_choice",
&"growth_member_id", &"training_committed", &"trained_member", &"trained_member_id",
&"fifth_reward_committed",
]
func set_screen(value: StringName) -> void:
screen = value
var mapped_page := Contracts.legacy_page_for_screen(value)
if mapped_page >= 0:
legacy_page = mapped_page
func reset_transient() -> void:
revision += 1
front_choice = ""
rear_choice = ""
selected_recruits.clear()
formation_commit_count = 0
notice = ""
restart_confirmation = false
battle_phase = 0
battle_playing = false
front_hp = 100
rear_hp = 100
enemy_hp = 100
guard_events = 0
rear_events = 0
victory_events = 0
phase_impact_committed.clear()
coin = 0
first_reward_committed = false
adaptation_choice = ""
adaptation_committed = false
guild_choice = ""
guild_committed = false
retained_item = ""
added_rear = ""
route_choice = ""
route_committed = false
route_reward_committed = false
training_choice = ""
growth_member_id = ""
training_committed = false
trained_member = ""
trained_member_id = ""
fifth_reward_committed = false
facts.clear()
func available_actions() -> Array[StringName]:
match screen:
Contracts.SCREEN_RECRUIT_SELECTION:
return [&"toggle_recruit", &"review_formation", &"home"]
Contracts.SCREEN_FORMATION_REVIEW:
return [&"revise_formation", &"commit_formation", &"home"]
Contracts.SCREEN_ADAPTATION:
return [&"choose_adaptation", &"commit_adaptation"]
Contracts.SCREEN_GUILD_RESPONSE:
return [&"choose_guild_response", &"commit_guild_response"]
Contracts.SCREEN_ROUTE_BOARD:
return [&"choose_route", &"review_route"]
Contracts.SCREEN_ROUTE_REVIEW:
return [&"cancel_route_review", &"commit_route"]
Contracts.SCREEN_GROWTH:
return [&"choose_growth", &"review_growth"]
Contracts.SCREEN_GROWTH_REVIEW:
return [&"cancel_growth_review", &"commit_growth"]
return []
func snapshot() -> Dictionary:
var ordered: Array[String] = []
for member_id in Contracts.AUTO_FORMATION_ORDER:
if selected_recruits.has(member_id):
ordered.append(member_id)
return {
"schema": Contracts.SNAPSHOT_SCHEMA,
"revision": revision,
"screen": screen,
"party": {
"selected_ids": selected_recruits.duplicate(),
"ordered_ids": ordered,
"lead_id": front_choice,
"second_id": rear_choice,
"added_id": added_rear,
"formation_commits": formation_commit_count,
},
"resources": {"coin": coin},
"decisions": {
"adaptation": adaptation_choice,
"guild_response": guild_choice,
"route": route_choice,
"growth_member": trained_member_id,
},
"locks": {
"adaptation": adaptation_committed,
"guild_response": guild_committed,
"route": route_committed,
"growth": training_committed,
},
"encounter": {
"id": encounter_id(),
"phase": battle_phase,
"playing": battle_playing,
"front_hp": front_hp,
"rear_hp": rear_hp,
"enemy_hp": enemy_hp,
"committed_impacts": committed_impact_ids(),
"elapsed": 0.0,
},
"rewards": {
"battle_1": first_reward_committed,
"route": route_reward_committed,
"battle_5": fifth_reward_committed,
},
"facts": facts.duplicate(true),
"available_actions": available_actions(),
}.duplicate(true)
func encounter_id() -> StringName:
match screen:
Contracts.SCREEN_BATTLE_1: return Contracts.ENCOUNTER_1
Contracts.SCREEN_BATTLE_2: return Contracts.ENCOUNTER_2
Contracts.SCREEN_BATTLE_3: return Contracts.ENCOUNTER_3
Contracts.SCREEN_BATTLE_4: return Contracts.ENCOUNTER_4
Contracts.SCREEN_BATTLE_5: return Contracts.ENCOUNTER_5
return &""
func committed_impact_ids() -> Array[StringName]:
var result: Array[StringName] = []
for key in phase_impact_committed:
result.append(StringName(str(key)))
return result
func invariants_hold() -> bool:
if selected_recruits.size() > 2:
return false
if formation_commit_count > 1:
return false
if front_hp < 0 or rear_hp < 0 or enemy_hp < 0 or coin < 0:
return false
return true
+270 -3694
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
class_name P91AnimationAdapter
extends RefCounted
var library: Dictionary = {}
func load_specs(specs: Dictionary) -> void:
library.clear()
for character in specs:
var loaded_states := {}
for state in specs[character]:
var spec: Dictionary = specs[character][state]
var frames: Array[Texture2D] = []
for index in range(int(spec["frames"])):
var path := "res://assets/animations/%s/frame-%d.png" % [spec["root"], index]
var texture := load(path) as Texture2D
assert(texture != null, "Missing animation texture: %s" % path)
frames.append(texture)
loaded_states[state] = {"frames": frames, "fps": float(spec["fps"]), "loop": bool(spec["loop"])}
library[character] = loaded_states
func frame_index(character: String, state: String, elapsed: float) -> int:
if not library.has(character):
return 0
var resolved := state if library[character].has(state) else "idle"
var spec: Dictionary = library[character][resolved]
var count: int = spec["frames"].size()
var raw := int(floor(maxf(0.0, elapsed) * float(spec["fps"])))
return raw % count if spec["loop"] else mini(raw, count - 1)
func texture(character: String, state: String, elapsed: float) -> Texture2D:
if not library.has(character):
return null
var resolved := state if library[character].has(state) else "idle"
return library[character][resolved]["frames"][frame_index(character, resolved, elapsed)]
+38
View File
@@ -0,0 +1,38 @@
class_name P91AudioAdapter
extends RefCounted
var player: AudioStreamPlayer
var playback: AudioStreamGeneratorPlayback
func attach(parent: Node) -> void:
var generator := AudioStreamGenerator.new()
generator.mix_rate = 22050.0
generator.buffer_length = 0.25
player = AudioStreamPlayer.new()
player.name = "PrototypeSFX"
player.bus = &"SFX"
player.playback_type = AudioServer.PLAYBACK_TYPE_STREAM
player.stream = generator
parent.add_child(player)
player.play()
playback = player.get_stream_playback()
func cleanup() -> void:
if player != null:
player.stop()
playback = null
player = null
func play(kind: StringName, muted: bool) -> void:
if muted or playback == null:
return
var frequency: float = {
&"select": 360.0, &"confirm": 440.0, &"depart": 300.0,
&"hit": 180.0, &"role": 540.0, &"victory": 680.0,
}.get(kind, 400.0)
var duration := 0.13 if kind in [&"hit", &"victory"] else 0.08
var frames := int(22050.0 * duration)
for index in range(frames):
var envelope := sin(PI * float(index) / float(frames)) * 0.035
var value := sin(TAU * frequency * float(index) / 22050.0) * envelope
playback.push_frame(Vector2(value, value))
+43
View File
@@ -0,0 +1,43 @@
class_name P91BattleClock
extends RefCounted
var elapsed := 0.0
var result_elapsed := 0.0
var second_elapsed := 0.0
var second_result_time := 0.0
var third_elapsed := 0.0
var third_result_time := 0.0
var fourth_elapsed := 0.0
var fourth_result_time := 0.0
var fifth_elapsed := 0.0
var fifth_result_time := 0.0
func reset() -> void:
elapsed = 0.0
result_elapsed = 0.0
second_elapsed = 0.0
second_result_time = 0.0
third_elapsed = 0.0
third_result_time = 0.0
fourth_elapsed = 0.0
fourth_result_time = 0.0
fifth_elapsed = 0.0
fifth_result_time = 0.0
func advance(delta: float, encounter: Dictionary, impact_at: float, duration: float, paused: bool) -> Dictionary:
if paused or not encounter.get("playing", false):
return {"impact_requests": [], "phase_complete": false, "phase_progress": 0.0}
var previous := elapsed
elapsed += delta
var requests: Array[Dictionary] = []
if impact_at >= 0.0 and previous < impact_at and elapsed >= impact_at:
requests.append({
"encounter_id": encounter.get("id", &""),
"phase": encounter.get("phase", 0),
"impact_id": StringName("%s:phase_%d" % [encounter.get("id", &""), encounter.get("phase", 0)]),
})
return {
"impact_requests": requests,
"phase_complete": duration > 0.0 and elapsed >= duration,
"phase_progress": clampf(elapsed / duration, 0.0, 1.0) if duration > 0.0 else 0.0,
}
@@ -0,0 +1,20 @@
class_name P91FeedbackPresenter
extends RefCounted
var active: Dictionary = {}
var clock := 0.0
var history: Array[Dictionary] = []
func present(event: Dictionary) -> void:
active = event.duplicate(true)
clock = 0.0
history.append(active.duplicate(true))
func clear() -> void:
active.clear()
clock = 0.0
history.clear()
func expire() -> void:
active.clear()
clock = 0.0
@@ -0,0 +1,18 @@
class_name P91PresentationSettings
extends RefCounted
var locale := "zh_CN"
var text_scale := 1.0
var reduced_motion := false
var muted := false
var audio_enabled := true
var low_power := false
func snapshot() -> Dictionary:
return {
"locale": StringName(locale),
"text_scale": text_scale,
"reduced_motion": reduced_motion,
"muted": muted,
"low_power": low_power,
}.duplicate(true)
+78
View File
@@ -0,0 +1,78 @@
class_name P91SessionPresenter
extends RefCounted
var host: Control
func _init(runtime_host: Control) -> void:
host = runtime_host
func render(snapshot: Dictionary, actions: Dictionary = {}) -> void:
if snapshot.is_empty() or not snapshot.has("screen"):
host.render_binding_error()
return
_reset_legacy_references()
clear_root(host.display_root)
var page: int = host.page
var background_texture: Texture2D = host.FIELD_BACKGROUND if page in host.BATTLE_PRESENTATION_PAGES else host.YARD_BACKGROUND
host.add_background(background_texture)
if host.portrait_layout():
host.render_portrait_notice()
else:
_dispatch_legacy_page(page)
if host.restart_confirmation:
host.render_restart_confirmation()
func _reset_legacy_references() -> void:
host.render_pending = false
host.front_sprite = null
host.rear_sprite = null
host.added_rear_sprite = null
host.enemy_sprite = null
host.battle_feedback_label = null
host.battle_feedback_vfx = null
host.victory_front_sprite = null
host.victory_rear_sprite = null
host.victory_enemy_sprite = null
host.projectile = null
func _dispatch_legacy_page(page: int) -> void:
match page:
host.AppPage.BOOT: host.render_boot()
host.AppPage.TITLE: host.render_title()
host.AppPage.MAIN_MENU: host.render_main_menu()
host.AppPage.GAME_INTRO: host.render_game_intro()
host.AppPage.SETTINGS: host.render_settings()
host.AppPage.FIRST_MISSION: host.render_first_mission()
host.AppPage.RECRUIT_SELECTION: host.render_recruit_selection()
host.AppPage.FRONT_SELECTION: host.render_role_selection(true)
host.AppPage.REAR_SELECTION: host.render_role_selection(false)
host.AppPage.FORMATION_REVIEW: host.render_formation_review()
host.AppPage.YARD: host.render_yard()
host.AppPage.BATTLE, host.AppPage.SECOND_BATTLE, host.AppPage.THIRD_BATTLE, host.AppPage.FOURTH_BATTLE, host.AppPage.FIFTH_BATTLE: host.render_battle()
host.AppPage.VICTORY: host.render_victory()
host.AppPage.ADAPTATION: host.render_adaptation()
host.AppPage.SECOND_RESULT: host.render_second_result()
host.AppPage.GUILD_RETURN: host.render_guild_return()
host.AppPage.SESSION_RESULT: host.render_session_result()
host.AppPage.ROUTE_BOARD: host.render_route_board()
host.AppPage.ROUTE_REVIEW: host.render_route_review()
host.AppPage.SECOND_CYCLE_RESULT: host.render_second_cycle_result()
host.AppPage.TRAINING: host.render_training()
host.AppPage.TRAINING_REVIEW: host.render_training_review()
host.AppPage.THIRD_CYCLE_RESULT: host.render_third_cycle_result()
func clear_root(root: Control) -> void:
for child in root.get_children():
root.remove_child(child)
child.queue_free()
func add_background(texture: Texture2D) -> void:
var background := TextureRect.new()
background.name = "WorldBackground"
background.texture = texture
background.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
background.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
background.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
background.mouse_filter = Control.MOUSE_FILTER_IGNORE
host.display_root.add_child(background)
+29
View File
@@ -0,0 +1,29 @@
class_name P91Widgets
extends RefCounted
static func label(value: String, font_size: int, color: Color) -> Label:
var result := Label.new()
result.text = value
result.add_theme_font_size_override("font_size", font_size)
result.add_theme_color_override("font_color", color)
return result
static func button(node_name: String, label_text: String, callback: Callable) -> Button:
var result := Button.new()
result.name = node_name
result.text = label_text
result.custom_minimum_size = Vector2(44, 44)
result.pressed.connect(callback, CONNECT_DEFERRED)
return result
static func style(fill: Color, border: Color, width: int) -> StyleBoxFlat:
var result := StyleBoxFlat.new()
result.bg_color = fill
result.border_color = border
result.set_border_width_all(width)
result.set_corner_radius_all(5)
result.content_margin_left = 10
result.content_margin_right = 10
result.content_margin_top = 7
result.content_margin_bottom = 7
return result