Files
gplan-2/game/presentation/views/standee_body.gd
T

480 lines
23 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
# 实现 GDD:design/ux/battle.md(B 区全站桩形态——2026-09-20 用户看对照样张拍板,台账 99:
# 主角与敌人均为无框立绘+底座,英雄卡/敌人卡卡片式整体退役禁再提)。
# 战斗舞台 Node2D 迁移批 Phase B 2026-09-26(循环 1 T3):站桩本体自 Control(旧 StandeeView,
# 退役删除)平移为 Node2D 世界精灵(ADR-0006:B 区战斗舞台归 Node2D)。行为面契约逐项保持:
# set_data/set_hero/_set_hp/set_idle/set_effects/set_effects_cleared_feedback/set_order/set_pending/
# _play_intent_motion/set_portrait_texture(tex,flip)/mark_dead/is_dead/flash_hit/_revive/
# _update_name_line/_refresh_portrait_glyph(调用点 hud_presenter/main/探针零改造目标——C3)。
# 等价平移基线 = 旧 Control 占位表现(C9):立绘贴图(flip/NEAREST/整数 2× 档)+ 底座 + 血条
# (直角递减 tween)+ 顺位徽标 + 意图徽标(弹入+立绘前倾一次性动画)+ 名字行(纯名+护甲类型
# 图标——2026-09-26 终裁 AC 不展示)+ 受击闪白 + 死亡灰化摇摆塌陷(禁瞬消)+ 复活 + 字形占位
# 回退(人/▂/▆/█/✕/敌)。批内代拍板默认(待追认):①状态图标行(slot_row)与悬停 tooltip 保留
# Control 载体挂壳层(tooltip 机制零返工)——本体的 slot_row 成员 = 壳层伴随件引用;
# ②状态行隐藏时序 = 随 mark_dead 同拍隐藏;③清空反馈微文案挂伴随件父层(壳层)。
# 文字全部经 draw_string + 世界字体(world.world_font 或 ThemeDB.fallback_font——C6 挂点链)。
# 坐标空间:本节点 position/box = SubViewport 世界内局部排布;壳层换算归
# BattleStageView.world_to_shell 单点(C4)。
# 无 class_name(类名缓存面纪律——BattleStageView 经 preload 使用)。
extends Node2D
const CHARGE_SKILL_IDS := ["heavy"]
# 护甲类型图标占位——形状+色相双通道(art-bible):轻甲=◇ 灰石中性 / 魔法护甲=◆ 小人蓝 /
# 重甲=▣ 深金(避开 Boss 专属紫赤与妨害紫)。(自旧 StandeeView 平移)
const ARMOR_ICONS := {"light": "◇", "ward": "◆", "heavy": "▣"}
var box := Vector2(150, 176) # 站桩盒尺寸(旧 Control custom_minimum_size;实际高随壳层 row 布局注入)
var font: Font = null # 世界字体槽位(null = ThemeDB.fallback_font——world.world_font 注入)
var slot_row: StatusSlotRow # 状态效果唯一展示面 = 壳层伴随件(挂 BattleStageView 壳层,本体进世界)
var _kind := "enemy"
var _portrait_sprite: Sprite2D = null # 资产挂点(null = 未落位,字形在位)
var _portrait_tex: Texture2D = null # 落位纹理引用(set_portrait_texture 装载;字形回退判据)
var _portrait_flip := false
# 主角三态切换(迭代 6 T3):state 名 → 纹理(battle_stage_view 组合点注册);缺态=保持现纹理
# (缺位回退链延伸——三态素材缺任一不炸不空)。敌站桩不注册不消费。
var _hero_tex := {}
var _hero_state := "idle"
var _state_revert_tw: Tween
var _order := 1
var _intent_glyph := ""
var _intent_col := Color.WHITE
var _intent_visible := false
var _intent_scale := 1.0: # 意图弹入动画参数(旧 tween scale 等价)
set(v):
_intent_scale = v
queue_redraw()
var _glyph_rot := 0.0: # 立绘前倾角(旧只转字形 Label——贴图态无此动画,等价平移)
set(v):
_glyph_rot = v
queue_redraw()
var _display_name := ""
var _enemy_id := ""
var _armor := "light"
var _dead := false
var _hp_max := 1.0
var _hp_cur := 0 # 血量数字显示面(HUD框架 验收「血量数字可读」——2026-09-26 用户点名补数)
var hp_value := 1.0: # 血条值 0..1(直角递减——tween_property 目标,逐行刷新重启不累积)
set(v):
hp_value = clampf(v, 0.0, 1.0)
queue_redraw()
var _hp_tween: Tween
var _hp_flash_tw: Tween
func _init(kind: String = "enemy") -> void:
_kind = kind
func _efont() -> Font:
return font if font != null else ThemeDB.fallback_font
func display_name() -> String:
return _display_name
# 血条存在性探针出口(T5 断言等价改写——旧 _hp_bar ProgressBar 退役,世界直角血条常在)
var has_world_hp_bar := true
# 意图徽标探针出口(T5——旧 _intent_badge.visible/text 改判)
func intent_probe() -> Dictionary:
return {"visible": _intent_visible, "text": _intent_glyph}
# 名字行探针出口(T5——旧 _name_label/_armor_icon.text 改判)
func name_line_probe() -> Dictionary:
return {"name": _display_name, "armor_icon": String(ARMOR_ICONS.get(_armor, "◇"))}
# ---- setter 面(投影契约与旧 StandeeView 一致——presenter/组合根零改排) ----
# data = 战斗结算 presentation_snapshot enemies[] 投影:{order, display_name, hp, max_hp, enemy_id,
# pending_skill/pending_type/pending_name, effects[](敌方 side 增益——各立绘同值呈现)}
func set_data(d: Dictionary) -> void:
_display_name = String(d.get("display_name", "敌人"))
_enemy_id = String(d.get("enemy_id", ""))
_armor = String(d.get("armor_type", "light"))
_hp_max = float(maxi(1, int(d.get("max_hp", 1))))
_set_hp(int(d.get("hp", 0)))
set_order(int(d.get("order", 1)))
set_pending(String(d.get("pending_type", "")), String(d.get("pending_skill", "")))
set_effects(d.get("effects", []))
_refresh_portrait_glyph()
if _dead and int(d.get("hp", 0)) > 0:
_revive()
queue_redraw()
func set_hero(hp: int, max_hp: int) -> void:
_hp_max = float(maxi(1, max_hp))
_set_hp(hp)
if _dead and hp > 0:
_revive() # 新战斗开场 HP 回正——主角死亡残位随之复活(敌侧 set_data 同款语义)
# 血条短 tween 递减(直角递减占位——密度/缓动终稿归战斗表现批);逐行刷新时重启 tween 不累积
func _set_hp(hp: int) -> void:
_hp_cur = clampi(hp, 0, int(_hp_max))
var target := float(_hp_cur) / _hp_max
if _hp_tween != null and _hp_tween.is_valid():
_hp_tween.kill()
_hp_tween = create_tween()
_hp_tween.tween_property(self, "hp_value", target, UiKit.motion(0.15))
# 主角/敌方共用——状态效果唯一展示面(壳层伴随件转发;空态 = 图标行隐藏不占位)
func set_effects(effects: Array) -> void:
if slot_row == null:
return
slot_row.set_effects(effects)
slot_row.visible = not effects.is_empty()
# battle_over:状态图标行全清可见反馈(微文案挂伴随件父层 = 壳层)
func set_effects_cleared_feedback() -> void:
if slot_row == null:
return
slot_row.visible = true
slot_row.set_cleared_feedback()
var note := UiKit.label("状态随战斗结束消散", 11, false, UiKit.STONE_FAINT)
note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
note.mouse_filter = Control.MOUSE_FILTER_IGNORE
var parent := slot_row.get_parent()
if parent != null:
parent.add_child(note)
note.position = slot_row.position + Vector2((slot_row.size.x - 134.0) * 0.5, slot_row.size.y)
var tw := create_tween()
tw.tween_interval(1.2)
tw.tween_property(note, "modulate:a", 0.0, UiKit.motion(0.8))
tw.tween_callback(note.queue_free)
func set_order(i: int) -> void:
# 顺位徽标 = 行主序生成序(固定顺位——敌人系统 顺位标记义务)
_order = i
queue_redraw()
# 摇定动作渲染(三档可见性):pending_type = 战斗结算 快照投影(basic/damage/buff/debuff;
# "" = 未摇定/死亡残位)。不显示技能名——类型级图标 + 一次性动作动画。
func set_pending(ptype: String, pid: String) -> void:
if _dead:
_intent_visible = false
queue_redraw()
return
if ptype == "" or ptype == "basic":
_intent_visible = false # 普通攻击零表示(完全不可预测——§2.4)
queue_redraw()
return
var glyph := "⚔" # 攻击系(damage)
var col: Color = UiKit.RED_MINE # 攻击预告红(危险红族——旗朱退役 2026-09-26,预告语义归封印色独占〔现行封印黑〕)
if ptype == "buff":
glyph = "✦" # 增益系
col = UiKit.HERO_BLUE
elif ptype == "debuff":
glyph = "☠" # 妨害系(新效应词汇批 2026-09-19)
col = UiKit.DEBUFF_PURPLE
if pid in CHARGE_SKILL_IDS:
col = UiKit.GOLD_DEEP # 重击类蓄力档(2026-09-18 用户拍板独立蓄力提示)
_intent_glyph = glyph
_intent_col = col
_intent_visible = true
_play_intent_motion()
# 意图三档动作动画终稿(样张 v2 冻结批 T3——§4.6.4 三档:①普通攻击=零表示〔set_pending 早退〕
# ②特殊准备=徽标弹入+二次弹跳(高举语义,运动手段 §2)③重击/硬化=缓推蓄力+定格微顿+回落;
# 三档运动签名互异,灰度/剪影层可辨)。摇定时一次性短动画,随后静帧保持。
func _play_intent_motion() -> void:
var tw := create_tween()
tw.tween_property(self, "_intent_scale", 1.6, UiKit.motion(0.10))
tw.tween_property(self, "_intent_scale", 1.0, UiKit.motion(0.16))
tw.parallel().tween_property(self, "_glyph_rot", 4.0, UiKit.motion(0.10))
tw.tween_property(self, "_glyph_rot", 0.0, UiKit.motion(0.14))
if _intent_col == UiKit.GOLD_DEEP:
tw.tween_property(self, "_intent_scale", 1.22, UiKit.motion(0.28)).set_trans(Tween.TRANS_SINE) # ③蓄力缓推
tw.tween_interval(UiKit.motion(0.40)) # 定格微顿
tw.tween_property(self, "_intent_scale", 1.0, UiKit.motion(0.18))
else:
tw.tween_property(self, "_intent_scale", 1.26, UiKit.motion(0.11)) # ②二次弹跳(高举)
tw.tween_property(self, "_intent_scale", 1.0, UiKit.motion(0.13))
# 资产挂点(战斗表现样张批):tex 落位后字形隐退、纹理立绘顶位——零代码换装契约;tex=null 回退字形。
# flip = 立绘朝向翻转(素材基准朝左时传 true 呈现朝右)。盒高 96px = 48px 立绘源整数 2×
# (圣经 §8.2 禁非整数缩放)——源换代时随源重算。
func set_portrait_texture(tex: Texture2D, flip: bool = false) -> void:
_portrait_flip = flip
if tex == null:
if _portrait_sprite != null:
_portrait_sprite.visible = false
queue_redraw()
return
_portrait_tex = tex
if _portrait_sprite == null:
_portrait_sprite = Sprite2D.new()
_portrait_sprite.centered = false
_portrait_sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST # 像素帧放大防糊(棋盘小人贴图批 2026-09-25)
add_child(_portrait_sprite)
_portrait_sprite.texture = tex
_portrait_sprite.flip_h = flip
_portrait_sprite.visible = true
_place_portrait()
func _place_portrait() -> void:
# 立绘盒 = (0,44)-(box.x, box.y-36)(旧 FULL_RECT offsets 等价);48 源 ×2 整数档居中
if _portrait_sprite == null or _portrait_tex == null:
return
var box_h := box.y - 44.0 - 36.0
var w := _portrait_tex.get_width() * 2.0
var h := _portrait_tex.get_height() * 2.0
_portrait_sprite.scale = Vector2(2.0, 2.0) # 整数 2×(C5)
_portrait_sprite.position = Vector2((box.x - w) * 0.5, 44.0 + (box_h - h) * 0.5)
func mark_dead() -> void:
if _dead:
return
_dead = true
modulate = Color(0.62, 0.62, 0.62, 0.9)
var tw := create_tween()
tw.tween_property(self, "rotation_degrees", 2.5, UiKit.motion(0.12))
tw.tween_property(self, "rotation_degrees", -1.5, UiKit.motion(0.1))
tw.tween_property(self, "rotation_degrees", 0.0, UiKit.motion(0.08))
_spawn_death_shards() # 碎裂退场(§4.6.4 语义硬规则:灰化+碎裂禁瞬间消失;§8.4 v2 冻结 5 块 0.45s)
_refresh_portrait_glyph()
_intent_visible = false # 死亡残位意图退位(旧同款)
_set_hp(0)
if slot_row != null:
slot_row.visible = false # 状态行随 mark_dead 同拍隐藏(批内代拍板②待追认)
# ---- 演出密度终稿(样张 v2 冻结批 T3——程序化 tween,零新帧资产) ----
const DEATH_SHARD_N := 5 # 碎块数(§8.4 冻结值)
const HIT_SPARK_N := 6 # 命中火花数(迭代 6 T4 终稿——打击瞬间可读性一拍)
var _death_shards := 0 # 探针出口计数(碎块节点回收后仍保留读数)
var _hit_sparks := 0 # 探针出口计数(命中火花累计)
var _flash_count := 0 # 探针出口计数(受击闪白触发次数——主角路由断言面)
# 死亡碎块弹飞:立绘盒中心五块碎石抛物线散落(程序化色块——确定性散布,禁全局随机)
func _spawn_death_shards() -> void:
_death_shards = DEATH_SHARD_N
var center := _portrait_rect().get_center()
for i in DEATH_SHARD_N:
var shard := ColorRect.new()
shard.color = UiKit.STONE if i % 2 == 0 else UiKit.STONE.darkened(0.2)
shard.size = Vector2(7, 5) + Vector2(2, 0) * float(i % 2)
shard.mouse_filter = Control.MOUSE_FILTER_IGNORE
shard.position = center + Vector2(float(i % 3) - 1.0, float(i / 3) - 0.5) * 6.0
shard.rotation = float(i) * 0.7
add_child(shard)
var dir := Vector2((float(i % 3) - 1.0) * 30.0, -36.0 - 6.0 * float(i / 3))
var peak := shard.position + dir
var stw := shard.create_tween()
stw.tween_property(shard, "position", peak, UiKit.motion(0.18)).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
stw.tween_property(shard, "position", peak + Vector2(dir.x * 0.4, 52.0), UiKit.motion(0.27)).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
stw.parallel().tween_property(shard, "modulate:a", 0.0, UiKit.motion(0.18)).set_delay(UiKit.motion(0.27))
stw.tween_callback(shard.queue_free)
# 演出探针出口(fx_probe——死亡碎裂/命中火花/闪白计数断言面)
func fx_probe() -> Dictionary:
return {"death_shards": _death_shards, "hit_sparks": _hit_sparks, "flash_count": _flash_count}
# 命中瞬间打击特效(迭代 6 T4 终稿):立绘盒中心六枚短火花放射(程序化色块,确定性方向
# i*60°——禁全局随机;亮金/暖白交替=物理打击语义,不占 危险红=雷 与 奖励金=收益 的语义位)。
# 弹道到达拍由 battle_stage_view 调度(on_step_row_fx 延迟 0.33s≈弹道飞行)。
func play_hit_impact() -> void:
var center := _portrait_rect().get_center()
for i in HIT_SPARK_N:
_hit_sparks += 1
var spark := ColorRect.new()
spark.color = UiKit.GOLD_LIT if i % 2 == 0 else UiKit.WARM
spark.size = Vector2(4, 3)
spark.mouse_filter = Control.MOUSE_FILTER_IGNORE
spark.position = center - spark.size * 0.5
add_child(spark)
var ang := TAU * float(i) / float(HIT_SPARK_N)
var dir := Vector2(cos(ang), sin(ang)) * 16.0
var stw := spark.create_tween()
stw.tween_property(spark, "position", spark.position + dir, UiKit.motion(0.12)).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
stw.parallel().tween_property(spark, "modulate:a", 0.0, UiKit.motion(0.12))
stw.tween_callback(spark.queue_free)
func is_dead() -> bool:
return _dead
# 受击闪白(结算演出占位——§2.2 受击反馈;强度时长复核迭代 6 T4=维持现行)
func flash_hit() -> void:
_flash_count += 1
if _hp_flash_tw != null and _hp_flash_tw.is_valid():
_hp_flash_tw.kill()
_hp_flash_tw = create_tween()
_hp_flash_tw.tween_property(self, "modulate", Color(1.6, 1.4, 1.2), UiKit.motion(0.08))
_hp_flash_tw.tween_property(self, "modulate", Color(1, 1, 1) if not _dead else Color(0.62, 0.62, 0.62, 0.9), UiKit.motion(0.2))
func _revive() -> void:
_dead = false
modulate = Color(1, 1, 1)
rotation_degrees = 0.0
if _kind == "hero":
_hero_state = "reset" # 强制回 idle 态(绕过同态早退)
set_hero_state("idle")
_refresh_portrait_glyph()
if slot_row != null:
slot_row.visible = true
queue_redraw()
func _refresh_portrait_glyph() -> void:
queue_redraw()
func _portrait_glyph() -> String:
# 字形回退已退役为纯缺位兜底(迭代 6 T2——八敌真图落位;旧三档体量字形 ▂/▆/█ 随退役,
# 相对体量语义由 48px 档统一比例承载)。缺位态:主角「人」/死亡「✕」/其余「敌」。
if _kind == "hero":
return "人"
if _dead:
return "✕"
return "敌"
# 立绘落位探针出口(迭代 6 T2——站桩纹理非空断言面;缺位回退链保持的对照面)
func portrait_probe() -> Dictionary:
return {"has_tex": _portrait_sprite != null and _portrait_sprite.texture != null}
# ---- 主角三态切换(迭代 6 T3——攻击演出→attack/受击→hit/结算回 idle;§8.4 帧数档挂点) ----
# 组合点注册(battle_stage_view _init——UiKit 常量 load_tex,缺位 null 不注册=该态保持现纹理)
func register_hero_state(state: String, tex: Texture2D) -> void:
if tex != null:
_hero_tex[state] = tex
# 状态切换:现纹理换注册态(attack/hit);未注册态名或缺位 = 保持现纹理不炸。
# 非 idle 态自动短驻回归 idle(0.35s——演出一拍即回,结算收尾另有显式 idle 兜底)。
func set_hero_state(state: String) -> void:
if _kind != "hero" or state == _hero_state:
return
if not _hero_tex.has(state) and state != "idle":
return
_hero_state = state
if _portrait_sprite != null and _hero_tex.has(state):
_portrait_sprite.texture = _hero_tex[state]
if _state_revert_tw != null and _state_revert_tw.is_valid():
_state_revert_tw.kill()
if state != "idle":
_state_revert_tw = create_tween()
_state_revert_tw.tween_interval(UiKit.motion(0.35))
_state_revert_tw.tween_callback(_settle_hero_idle)
func _settle_hero_idle() -> void:
_hero_state = "idle"
if _portrait_sprite != null and _hero_tex.has("idle"):
_portrait_sprite.texture = _hero_tex["idle"]
# 三态探针出口(fx_probe——当前态名+注册态集合)
func hero_state_probe() -> Dictionary:
return {"state": _hero_state, "registered": _hero_tex.keys()}
func _portrait_color() -> Color:
return UiKit.HERO_BLUE if _kind == "hero" else UiKit.RED_DEEP
# 立绘盒矩形(世界局部):(0,44)-(box.x, box.y-36)——贴图与字形共用
func _portrait_rect() -> Rect2:
return Rect2(0, 44, box.x, box.y - 80.0)
func _draw() -> void:
var f := _efont()
# 椭圆落影(圣经 v3.5 批 B4 2026-09-30——§6.1 地台落影压脚:方向稿椭圆 (0,0,0,70),压在地台
# 上缘周围;24 段多边形近似椭圆,中心=台面上方 6px、横半径=台宽一半)
var sh_center := Vector2(box.x * 0.5, box.y - 24.0)
var sh_rx := (box.x - 36.0) * 0.5
var sh_ry := 9.0
var pts := PackedVector2Array()
for i in 24:
var ang := TAU * float(i) / 24.0
pts.append(sh_center + Vector2(cos(ang) * sh_rx, sin(ang) * sh_ry))
draw_colored_polygon(pts, Color(0, 0, 0, 0.27))
# 石板地台(v3.5——方向稿取色:板身/描边/顶缘受光线 2px——取色单点收 UiKit.STAND_STONE 族
# (T4 裸色收口);几何沿用现行底座位〔margin 26/高 10→方向稿 18/12——按方向稿对齐〕)
draw_rect(Rect2(18.0, box.y - 18.0, box.x - 36.0, 12.0), UiKit.STAND_STONE, true)
draw_rect(Rect2(18.0, box.y - 18.0, box.x - 36.0, 12.0), UiKit.STAND_STONE_EDGE, false, 1.0)
draw_rect(Rect2(18.0, box.y - 18.0, box.x - 36.0, 2.0), UiKit.STAND_STONE_RIM, true)
# 血条(直角递减):敌附顺位徽标(GOLD_DEEP ①②③)——旧 top VBox 几何等价(左 8 / 右 -8)
var bar_x := 8.0
if _kind == "enemy":
var order_txt := String("①②③"[clampi(_order - 1, 0, 2)])
f.draw_string(get_canvas_item(), Vector2(bar_x, 12), order_txt,
HORIZONTAL_ALIGNMENT_LEFT, -1, 13, UiKit.GOLD_DEEP)
bar_x += 20.0
var bar_w := box.x - 8.0 - bar_x
draw_rect(Rect2(bar_x, 2, bar_w, 10), Color(0.10, 0.10, 0.11)) # 条底(引擎默认主题深底近似平移)
var fill_w := bar_w * hp_value
if fill_w > 0.0:
draw_rect(Rect2(bar_x, 2, fill_w, 10), Color(0.41, 0.61, 0.91)) # 直角填充(默认主题蓝近似——直角递减)
# 血量数字「当前/最大」居中压条(白字+深描边双通道——11px 于 10px 条带垂直居中;
# 数字=目标值即时刷新,条 tween 只管填充宽度;死残位 0/max 照常可读)
var hp_txt := "%d/%d" % [_hp_cur, int(_hp_max)]
var hts: Vector2 = f.get_string_size(hp_txt, HORIZONTAL_ALIGNMENT_LEFT, -1, 11)
var hbase := Vector2(bar_x + (bar_w - hts.x) * 0.5, 2.0 + 5.0 + 4.0)
f.draw_string(get_canvas_item(), hbase + Vector2(1, 1), hp_txt, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color(0.10, 0.08, 0.06, 0.85))
f.draw_string(get_canvas_item(), hbase, hp_txt, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color(1, 1, 1, 0.95))
# 立绘:贴图在位 = Sprite2D(set_portrait_texture 落位);缺位 = 字形回退
if _portrait_sprite == null or _portrait_sprite.texture == null or not _portrait_sprite.visible:
var prect := _portrait_rect()
var glyph := _portrait_glyph()
var center := prect.get_center()
# 坐标系注记(2026-09-26 错位修复):draw_set_transform 原点已置于 center——draw_string 的
# pos 是**变换后的局部坐标**,须传相对 center 的偏移(-w/2 水平居中、+0.35h 基线下移);
# 旧实现传 center-… = 双重偏移,字形画到盒外(实机敌人立绘区空白的真因,截图取证)
draw_set_transform(center, deg_to_rad(_glyph_rot), Vector2.ONE)
var ts := f.get_string_size(glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, 42)
f.draw_string(get_canvas_item(), Vector2(-ts.x * 0.5, ts.y * 0.35), glyph,
HORIZONTAL_ALIGNMENT_LEFT, -1, 42, _portrait_color())
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
# 意图徽标(敌 only——立绘上半部居中悬浮,CENTER_TOP 74..98 等价):弹入 scale 动画
if _intent_visible:
var icenter := Vector2(box.x * 0.5, 86.0)
draw_set_transform(icenter, 0.0, Vector2(_intent_scale, _intent_scale))
var its := f.get_string_size(_intent_glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, 15)
f.draw_string(get_canvas_item(), Vector2(-its.x * 0.5, its.y * 0.35), _intent_glyph,
HORIZONTAL_ALIGNMENT_LEFT, -1, 15, _intent_col) # 局部坐标(同上注记——双重偏移修复)
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
# 底座下 护甲类型图标+名字 常驻行(敌 only——2026-09-26 用户终裁:AC 数值不展示,类型图标化)
if _kind == "enemy":
var armor_txt := String(ARMOR_ICONS.get(_armor, "◇"))
var col: Color = UiKit.WARM_FAINT # 轻甲 = 暖白弱化(v3.5 暗底双色墨制——原灰石对暗底不可读)
match _armor:
"ward":
col = UiKit.HERO_BLUE # 魔法护甲 = 小人蓝
"heavy":
col = UiKit.GOLD_DEEP # 重甲 = 深金
var name_w := f.get_string_size(_display_name, HORIZONTAL_ALIGNMENT_LEFT, -1, 12).x
var armor_w := f.get_string_size(armor_txt, HORIZONTAL_ALIGNMENT_LEFT, -1, 12).x
var total_w := armor_w + 3.0 + name_w
var baseline := Vector2((box.x - total_w) * 0.5, box.y - 1.0)
f.draw_string(get_canvas_item(), baseline, armor_txt, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, col)
f.draw_string(get_canvas_item(), baseline + Vector2(armor_w + 3.0, 0), _display_name,
HORIZONTAL_ALIGNMENT_LEFT, -1, 12, UiKit.WARM) # v3.5 暗底双色墨制(原 INK_TITLE 深墨对暗底不可读)