refactor(runtime): extract P9.1 foundation boundaries
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)]
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user