Files
gplan-2/game/presentation/hud_presenter.gd
T

331 lines
17 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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.run.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.crossrun.feats.rows as Array).is_empty() or held.is_empty():
return rlines
var counts := {}
for rid in held:
var rrow: Dictionary[Variant, Variant] = db.crossrun.feats.row_by_id(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.combat.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.run.level.level_count():
var li: int = i + 1
clears.append({"display_name": String(db.run.level.row_by_level_index(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.run.level.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))