331 lines
16 KiB
GDScript
331 lines
16 KiB
GDScript
## VERTICAL SLICE - NOT FOR PRODUCTION
|
||
## 呈现装配器:核心状态 → 呈现文案的全部翻译集中于此(战斗屏 B/C/D 区文案与投影),静态无状态。
|
||
## 核心读取只走 settlement.presentation_snapshot() 成文快照面——禁链式穿透 settlement.enemy_system.* / status_effects 一类内部路径。
|
||
## 封印/拔/移动反馈与 AP/RAP 无推送信号——组合根每次动作/结算返回后必须重查 refresh,漏调即陈旧呈现。
|
||
class_name HudPresenter
|
||
extends RefCounted
|
||
|
||
const Narrator := preload("res://presentation/battle_narrator.gd")
|
||
|
||
|
||
# 每次结算行/轮次/节点变化/动作返回后调用(组合根全量重查装配)
|
||
# 钱包 GP/钥石不在本装配面投喂(中/大循环资源迁出战斗屏——信息归属单一展示面原则);
|
||
# HP/状态效果单投喂 B 区站桩(B/C 双投喂属重复展示——单一展示面原则)
|
||
static func refresh(stage: BattleStageView, info: HeroInfoPanelView, ap_bar: ApBarView,
|
||
settlement, runner, level_active: bool, db, refresh_remaining: int) -> void:
|
||
# 特性列表(runner 统一加算区投影:商店购买与关卡大奖励
|
||
# 共账本〔level_runner bonus{atk,max_hp,armor_class}〕;空加算=整节隐藏〔视图内空态纪律〕。
|
||
# 呈现只读投影零核心触碰;行文格式=白名单项)
|
||
if runner != null and level_active:
|
||
var b: Dictionary[Variant, Variant] = runner.journal().bonus_snapshot() # 加算区住加算日记
|
||
var lines: Array[String] = []
|
||
if int(b.get("atk", 0)) != 0:
|
||
lines.append("攻击 %+d" % int(b.get("atk", 0)))
|
||
if int(b.get("max_hp", 0)) != 0:
|
||
lines.append("生命上限 %+d" % int(b.get("max_hp", 0)))
|
||
if int(b.get("armor_class", 0)) != 0:
|
||
lines.append("护甲 AC %+d" % int(b.get("armor_class", 0)))
|
||
info.set_level_bonuses(lines)
|
||
else:
|
||
info.set_level_bonuses([])
|
||
# 特性持有投影(「局内奖励选了什么」须可读:
|
||
# 逐件行「件名 ×N」;常驻可读+空态隐藏)
|
||
if runner != null and level_active:
|
||
info.set_feat_holdings(feat_holdings_lines(db, runner.feats_held()))
|
||
else:
|
||
info.set_feat_holdings([])
|
||
if settlement == null:
|
||
info.set_board_variants([]) # 战斗域角标出战斗即隐(空态纪律)
|
||
_refresh_out_of_battle(stage, info, ap_bar, runner, level_active, db)
|
||
return
|
||
var s: Dictionary[Variant, Variant] = settlement.presentation_snapshot()
|
||
# 地形变异常驻角标投影(整图变异快照 → 文案行;
|
||
# 空态隐藏=特性段先例;战斗全程常驻防中局遗忘)
|
||
info.set_board_variants(board_variant_lines(db, s.get("board_variants", [])))
|
||
var hero: Dictionary[Variant, Variant] = s.hero
|
||
if hero.is_empty() or not bool(hero.get("alive", false)):
|
||
_refresh_out_of_battle(stage, info, ap_bar, runner, level_active, db)
|
||
return
|
||
_refresh_hero(stage, info, s)
|
||
_refresh_economy(ap_bar, s, db, refresh_remaining)
|
||
stage.set_enemies(s.enemies)
|
||
|
||
|
||
# ---- B 区主角站桩(HP/状态唯一展示面)+ C 区数据框(攻击/AC) ----
|
||
|
||
static func _refresh_hero(stage: BattleStageView, info: HeroInfoPanelView, s: Dictionary) -> void:
|
||
var hero: Dictionary[Variant, Variant] = s.hero
|
||
var hp: int = int(hero.hp)
|
||
var max_hp: int = int(hero.max_hp)
|
||
var atk: int = int(hero.atk)
|
||
# 修正 = attack_up 池有效值(hero_offense);来源角标 = 效果 display_name(快照 hero_effects 投影)
|
||
var mod_total := 0
|
||
var sources: Array[String] = []
|
||
for eff in s.hero_effects:
|
||
if String(eff.effect_id) == "attack_up":
|
||
mod_total += int(eff.effective_value)
|
||
sources.append("%s×%d" % [String(eff.display_name), int(eff.layers)])
|
||
var mod_text := ""
|
||
if mod_total != 0:
|
||
mod_text = "(%+d%% %s)" % [mod_total, "·".join(sources)]
|
||
# B 区站桩 = HP 与状态效果唯一展示面(单一展示面原则)——C 区不重复投喂
|
||
stage.hero_standee.set_hero(hp, max_hp)
|
||
stage.hero_standee.set_effects(s.hero_effects)
|
||
info.set_attack(atk, mod_text)
|
||
# 我方 AC 唯一展示面 = C 区——快照 armor_class 含战斗内 ac_up 折算
|
||
info.set_armor(int(hero.get("armor_class", 0)))
|
||
|
||
|
||
# ---- 行动经济(AP 点阵 / RAP / 弃图刷新按钮态——RAP 读数在本条) ----
|
||
|
||
static func _refresh_economy(ap_bar: ApBarView, s: Dictionary, db: ConfigDb, refresh_remaining: int) -> void:
|
||
ap_bar.set_ap(int(s.ap), int(db.action_economy.ap_per_round))
|
||
ap_bar.set_rap(int(s.rap))
|
||
var digging := String(s.phase) == ActionEconomy.PHASE_ACTIVE
|
||
ap_bar.set_refresh_state(refresh_remaining,
|
||
bool(s.can_refresh) and digging) # 无 Waiting 代理——UI 仅挖掘期受理
|
||
ap_bar.set_stop_enabled(bool(s.hero.get("alive", false)) and digging)
|
||
|
||
|
||
# 特性持有文案(战斗 C 区与关卡选择页两处共用;空持有=空数组。
|
||
# 行形态=逐件「件名 ×N」(非逐流派聚合)——表行
|
||
# 无 archetype 字段,聚合形态会空键兜底实显「流派 ×N · 档一」僵尸文案;流派档位非现行机制)
|
||
static func feat_holdings_lines(db: ConfigDb, held: Array) -> Array[String]:
|
||
var rlines: Array[String] = []
|
||
if db == null or db.feats == null or (db.feats.rows as Array).is_empty() or held.is_empty():
|
||
return rlines
|
||
var counts := {}
|
||
for rid in held:
|
||
var rrow: Dictionary[Variant, Variant] = db.feat_row(String(rid))
|
||
var fname := String(rrow.get("display_name", String(rid)))
|
||
counts[fname] = int(counts.get(fname, 0)) + 1
|
||
for fname in counts.keys():
|
||
rlines.append("%s ×%d" % [String(fname), int(counts[fname])])
|
||
return rlines
|
||
|
||
|
||
# 地形变异常驻角标文案(整图变异 id → 文案行;
|
||
# 空集 = 空数组=空态隐藏。三行制:变异名+两行效果说明(C 区 240px 宽防裁切)。
|
||
# 文案用常规表述,不用俚语
|
||
static func board_variant_lines(db: ConfigDb, ids: Array) -> Array[String]:
|
||
var lines: Array[String] = []
|
||
if db == null or db.variation == null:
|
||
return lines
|
||
for vid in ids:
|
||
var row: Dictionary[Variant, Variant] = db.variation.row_by_id(String(vid))
|
||
if not row.is_empty():
|
||
lines.append(String(row.get("display_name", vid)))
|
||
lines.append("回复与护甲资源减少")
|
||
lines.append("攻击资源增多")
|
||
return lines
|
||
|
||
|
||
# ---- 战斗收尾反馈与结算视觉同步(组合根直改视图一律收敛入此) ----
|
||
|
||
# battle_over 收尾反馈:死亡兜底灰化 → 主角态回 idle → RAP 清零脉冲(调试收尾路)→ 状态全清可见反馈。
|
||
# 调试/冒险两路共用;reset_rap=false = 冒险收束(RAP 清零由战斗末拍已毕,收束拍不再重放脉冲)。
|
||
static func battle_end_feedback(stage: BattleStageView, ap_bar: ApBarView, reset_rap: bool) -> void:
|
||
stage.gray_all_enemies() # 死亡兜底:站桩灰化残位(禁瞬消语义的战斗收尾面)
|
||
stage.settle_hero_idle() # 主角态回 idle(三态收尾)
|
||
if reset_rap:
|
||
ap_bar.set_rap(0) # RAP 清零脉冲 + 微文案(载体=D 区 AP 条)
|
||
stage.hero_standee.set_effects_cleared_feedback() # 状态全清可见反馈(增益状态×4④——载体迁 B 区站桩)
|
||
|
||
|
||
# 结算阶段视觉同步(round_end → 下一轮):D 区去饱和 + 禁挖光 + 明细带 settle/dig 换挡 + 轮次文案。
|
||
# 光带演出(play_round_sweep)属时序敏感触发,留在组合根(上升沿检测随行)——本条只做态驱动 setter。
|
||
static func sync_settlement_visuals(stage: BattleStageView, board_view: BoardView,
|
||
log_view: BattleLogView, settling: bool, phase_text: String) -> void:
|
||
board_view.set_desaturated(settling)
|
||
board_view.set_dig_glow_enabled(not settling)
|
||
stage.set_settle_focus(settling)
|
||
log_view.set_mode("settle" if settling else "dig")
|
||
stage.set_round_text(phase_text)
|
||
|
||
|
||
# 战斗外(节点间隙/关卡结束)——HP 读 runner 携带值
|
||
static func _refresh_out_of_battle(stage: BattleStageView, info: HeroInfoPanelView,
|
||
ap_bar: ApBarView, runner, level_active: bool, db) -> void:
|
||
if not level_active:
|
||
return
|
||
if runner != null:
|
||
info.set_attack(runner.effective_atk(), "(关卡推进中)")
|
||
info.set_armor(runner.effective_ac())
|
||
stage.hero_standee.set_hero(runner.hero_hp, runner.effective_max_hp())
|
||
stage.hero_standee.set_effects([])
|
||
stage.clear_enemies() # 节点间隙清敌方站桩残位(死亡瞬间的灰化呈现已在结算行驱动完成)
|
||
ap_bar.set_rap(0) # battle_over RAP 清零反馈(行动经济 义务——载体=AP 条)
|
||
|
||
|
||
# 三源拒绝可感知(排雷玩法 reject_reason/sub_reason → 玩家文案;pattern 1 双源拒绝反馈)
|
||
static func outcome_text(outcome: Dictionary) -> String:
|
||
if outcome.accepted:
|
||
return "执行完成。"
|
||
match String(outcome.get("reject_reason", "")):
|
||
"board_face":
|
||
var sub := String(outcome.get("sub_reason", ""))
|
||
var texts := {
|
||
"not_adjacent": "不能执行:目标与小人不相邻",
|
||
"target_revealed": "不能执行:目标已开",
|
||
"target_concealed": "不能移动:目标未开",
|
||
"seal_present": "不能封印:此格已有封印印记(封印为一次性承诺——相邻时左键直接挖开)",
|
||
"no_path": "不能移动:已探明区不连通",
|
||
"out_of_catalog": "无效目标",
|
||
}
|
||
return texts.get(sub, "棋盘面拒绝: " + sub)
|
||
"resource_face":
|
||
return "AP 不足。"
|
||
"phase":
|
||
return "结算中,非行动阶段。"
|
||
"board_invalid":
|
||
return "棋盘数据非法(fail-fast)。"
|
||
_:
|
||
return "动作被拒。"
|
||
|
||
|
||
# ---- 批量模拟报告装配(文案归 presenter——视图只 set_text) ----
|
||
|
||
static func tune_sim_report(n: int, atk_pct: int, hp_pct: int, wins: int, dmg_all: Array, hp_left_win: Array, hp_boss: Array, rounds_all: Array, death_nodes: Dictionary, base_hp: int) -> String:
|
||
var lines: Array[String] = []
|
||
lines.append("[b]模拟 N=%d(atk+%d%% / hp+%d%%)[/b]" % [n, atk_pct, hp_pct])
|
||
lines.append("关卡完成率 [color=#%s]%.0f%%[/color](%d/%d)" % [UiKit.HEX_LOG_WIN, 100.0 * float(wins) / float(maxi(1, n)), wins, n])
|
||
lines.append("整关承伤:均 %.1f · p90 %.0f(基础 HP %d)" % [_mean(dmg_all), _p90(dmg_all), base_hp])
|
||
if not hp_left_win.is_empty():
|
||
lines.append("关卡完成剩余 HP 均 %.1f · 进 Boss HP 均 %.1f" % [_mean(hp_left_win), _mean(hp_boss)])
|
||
lines.append("平均轮数 %.1f" % _mean(rounds_all))
|
||
if death_nodes.is_empty():
|
||
lines.append("败北节点:—")
|
||
else:
|
||
var parts: Array[String] = []
|
||
for k in death_nodes.keys():
|
||
parts.append("%s×%d" % [k, death_nodes[k]])
|
||
lines.append("败北节点:%s(0-3=战斗序,4=Boss)" % " ".join(parts))
|
||
return "\n".join(lines)
|
||
|
||
|
||
static func _mean(a: Array) -> float:
|
||
if a.is_empty():
|
||
return 0.0
|
||
var t := 0.0
|
||
for v in a:
|
||
t += float(v)
|
||
return t / a.size()
|
||
|
||
|
||
static func _p90(a: Array) -> float:
|
||
if a.is_empty():
|
||
return 0.0
|
||
var b := a.duplicate()
|
||
b.sort()
|
||
return float(b[mini(b.size() - 1, int(0.9 * b.size()))])
|
||
|
||
|
||
# ---- 组合根判离承接·数据投影族(静态无状态——db/save/meta/runner 现值由调用侧传参) ----
|
||
|
||
# 状态面板快照投影(数据只读同源:每次开层实时读访问器现值拼装——
|
||
# 快照仅服务单次开层生命周期(open_status 重建各行),跨开层零持有;零写调用。
|
||
# 八键换代(迭代 36 卡③):跨关解锁概览退役(营地/生涯承载)——只留本局信息;
|
||
# ledger 直取下标(键缺失=致命,禁 .get 缺省留门——fail-fast 红线);禁 null 守卫软回退
|
||
# (两入口开层时 runner 恒在役——状态面板卡 F3 证据链)
|
||
static func status_data(db: ConfigDb, meta: MetaProgressionCore, runner: Variant, treasure_ui: Variant) -> Dictionary:
|
||
return {
|
||
"balance": meta.meta_state.keystone_balance(),
|
||
"hero_hp": int(runner.hero_hp), # LevelRunner 属性面(_ledger 直取下标——键缺失=致命)
|
||
"max_hp": runner.effective_max_hp(),
|
||
"atk": runner.effective_atk(),
|
||
"ac": runner.effective_ac(),
|
||
"gp": int(runner.gp),
|
||
"curios": status_curios(treasure_ui),
|
||
"feats": feat_holdings_lines(db, runner.feats_held()),
|
||
}
|
||
|
||
|
||
# 秘宝行映射(inventory_projection 现役投影逐行取 name/used 两键——受理序沿库存投影;
|
||
# 禁第二份投影:件名换算已在 inventory_projection 收口,此处只做键裁剪)
|
||
static func status_curios(treasure_ui: Variant) -> Array:
|
||
var out: Array[Variant] = []
|
||
for row in (treasure_ui.inventory_projection() as Array):
|
||
var r: Dictionary[Variant, Variant] = row
|
||
out.append({"name": String(r.get("name", "")), "used": bool(r.get("used", false))})
|
||
return out
|
||
|
||
|
||
# 生涯页快照投影(只读同源——每次开层实时读 SaveManager 计数访问器 + MetaProgression 访问器
|
||
# 现值拼装(状态面板 status_data 同构);快照仅服务单次开层生命周期,跨开层零持有;零写调用。
|
||
# 生涯页只统计终身计数——不统计天赋树与账号成长态,
|
||
# 故 forge_levels_total/workshop/tree 三投影不在本投影内)
|
||
static func career_data(db: ConfigDb, save_manager: SaveManagerCore, meta: MetaProgressionCore) -> Dictionary:
|
||
var clears: Array[Dictionary] = []
|
||
var boss_first := 0
|
||
for i in db.level_count():
|
||
var li: int = i + 1
|
||
clears.append({"display_name": String(db.level_row(li).get("display_name", "")),
|
||
"count": save_manager.meta_state.career_state().clear_counts.get(str(li), 0)})
|
||
if save_manager.meta_state.career_state().boss_first_kill.has(str(li)):
|
||
boss_first += 1
|
||
return {
|
||
"total": save_manager.meta_state.career_state().adventure_total,
|
||
"cleared": save_manager.meta_state.career_state().adventure_cleared,
|
||
"clears": clears,
|
||
"boss_first_kills": boss_first,
|
||
"boss_first_total": db.level_count(),
|
||
"boss_kills": save_manager.meta_state.career_state().boss_kill_count,
|
||
"keystone": meta.meta_state.keystone_balance(),
|
||
}
|
||
|
||
|
||
# 槽数据快照(只读同源——每次开层实时经卡① 窥视 API 逐槽取数拼装;零写调用)。
|
||
# occupied = has_file ∧ readable:坏槽/高版本槽按空槽呈现(Q2 裁甲——进档/新档均经卡① 兜底链接管);
|
||
# 摘要仅三项(裁决 4),has_file 只是空/有档操作集二分判定来处(非摘要项)。
|
||
static func save_select_data(save_manager: SaveManagerCore) -> Dictionary:
|
||
var slots: Array[Dictionary] = []
|
||
for i in range(1, save_manager.SLOT_COUNT + 1):
|
||
var p := save_manager.slots.peek_slot_summary(i) # 推断 Dictionary(窥视返回合同——裸注解触 R1、参数化赋值运行期严格拒)
|
||
slots.append({"slot": i,
|
||
"occupied": bool(p.get("has_file", false)) and bool(p.get("readable", false)),
|
||
"keystone": int(p.get("keystone", 0)),
|
||
"cleared": int(p.get("cleared", 0)),
|
||
"total": int(p.get("total", 0))})
|
||
return {"slots": slots}
|
||
|
||
|
||
# 营地投影(承接件单点转发 camp_shop_presenter——组合根只留探针缝薄委托)
|
||
static func camp_data(pres: Variant) -> Dictionary:
|
||
return pres.camp_data()
|
||
|
||
|
||
# ---- 组合根判离承接·文案拼装三件(纯拼装零状态) ----
|
||
|
||
# 战利品合计行(runner 缺位 = 续玩恢复面:resume_adventure 的 bonus_pending 分支在 level_started
|
||
# 之前同步 curio_choice_presented.emit〔resume 提前 return〕,本行求值时 runner 尚未赋值;
|
||
# 合计改读断点快照 gp 兜底——恢复源快照此刻必在档,语义=续玩恢复面的合计即存档合计)
|
||
static func gp_summary_text(payload: Dictionary, runner: Variant, save_manager: SaveManagerCore) -> String:
|
||
if runner == null:
|
||
var bp: Dictionary[Variant, Variant] = save_manager.breakpoint_snapshot()
|
||
return "战利品 GP +%d · 合计 %d" % [int(payload.get("gp_dropped", 0)), int(bp.get("gp", 0))]
|
||
return "战利品 GP +%d · 合计 %d" % [int(payload.get("gp_dropped", 0)), int(runner.gp)]
|
||
|
||
|
||
# 结算三行账·本关加成行装配(呈现只读投影——runner.bonus 统一加算区同源;零加成=行隐藏)
|
||
static func bonus_summary_text(runner: Variant) -> String:
|
||
if runner == null:
|
||
return ""
|
||
var b: Dictionary[Variant, Variant] = runner.journal().bonus_snapshot() # 加算区住加算日记
|
||
var parts: Array[String] = []
|
||
if int(b.get("atk", 0)) != 0:
|
||
parts.append("攻 %+d" % int(b.get("atk", 0)))
|
||
if int(b.get("max_hp", 0)) != 0:
|
||
parts.append("上限 %+d" % int(b.get("max_hp", 0)))
|
||
if int(b.get("armor_class", 0)) != 0:
|
||
parts.append("AC %+d" % int(b.get("armor_class", 0)))
|
||
return "本关加成 " + " · ".join(parts)
|
||
|
||
|
||
# 相位行(文案装配归 narrator——settlement 缺位 = 非战斗态;相位取数经 presentation_snapshot 成文面)
|
||
static func phase_text(settlement: BattleOrchestrator, runner: Variant, boss_active: bool, db: ConfigDb) -> String:
|
||
if settlement == null:
|
||
return "第 — 轮 · —"
|
||
return Narrator.phase_text(int(settlement.round_index), String(settlement.battle_result),
|
||
String(settlement.presentation_snapshot().get("phase", "")) == ActionEconomy.PHASE_WAITING,
|
||
boss_active, Narrator.boss_name(runner, db))
|