diff --git a/game/presentation/views/battle_end_overlay.gd b/game/presentation/views/battle_end_overlay.gd index 9f07e1e6..a3563fb1 100644 --- a/game/presentation/views/battle_end_overlay.gd +++ b/game/presentation/views/battle_end_overlay.gd @@ -63,6 +63,7 @@ func open_victory(verdict_line: String, continue_label: String, board_rect: Rect _board_rect = board_rect visible = true _play_seal_fall() + _play_crest_assemble() # 碎砖拼纹章(§2.4——样张 v2 冻结批 T3 终稿) _play_flag_recall(0) # 胜利不放旗收回 @@ -75,12 +76,14 @@ func open_defeat(verdict_line: String, continue_label: String, board_rect: Rect2 _board_rect = board_rect visible = true _seal.visible = false + _play_dust_sadle() # 坐砖掸灰(§2.5——终稿) _play_flag_recall(8) func close() -> void: visible = false _seal.visible = false + _clear_crest_bricks() func is_open() -> bool: @@ -105,6 +108,7 @@ func _play_seal_fall() -> void: # 旗化光点收回(失败——插过的旗逐面化光点收回占位,§2.5) func _play_flag_recall(count: int) -> void: + _fx_flag_dots = count for i in count: var dot := ColorRect.new() dot.color = COL_GOLD @@ -119,3 +123,62 @@ func _play_flag_recall(count: int) -> void: tw.tween_property(dot, "position:y", dot.position.y - 120.0, UiKit.motion(0.8)) tw.parallel().tween_property(dot, "modulate:a", 0.0, UiKit.motion(0.8)) tw.tween_callback(dot.queue_free) + + +# ---- 演出密度终稿(样张 v2 冻结批 T3——程序化 tween,确定性散布,零新帧资产) ---- + +var _crest_bricks: Array = [] # 纹章碎砖驻留面(close 时回收) +var _fx_crest_shards := 0 +var _fx_dust_motes := 0 +var _fx_flag_dots := 0 + + +# 碎砖拼纹章(胜利 §2.4——蜡封落定后十枚碎砖自四周向蜡封外环收拢拼成纹章环,驻留至 close) +func _play_crest_assemble() -> void: + _clear_crest_bricks() + _fx_crest_shards = 10 + var c := _board_rect.get_center() if _board_rect.size != Vector2.ZERO else get_viewport_rect().size * 0.5 + for i in 10: + var brick := ColorRect.new() + brick.color = UiKit.BRICK if i % 2 == 0 else UiKit.FLOOR # 碎砖=砖面族色(掩尘/裸土——图底同源) + brick.size = Vector2(12, 7) + brick.mouse_filter = Control.MOUSE_FILTER_IGNORE + var ang := TAU * float(i) / 10.0 + var ring := c + Vector2(cos(ang), sin(ang)) * 64.0 + brick.position = c + Vector2(cos(ang), sin(ang)) * 200.0 # 自四周场外入场 + add_child(brick) + _crest_bricks.append(brick) + var btw := brick.create_tween() + btw.tween_interval(UiKit.motion(0.52)) # 待蜡封落定(seal 0.42+0.22 弹跳中段) + btw.tween_property(brick, "position", ring - brick.size * 0.5, UiKit.motion(0.4)).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) + btw.parallel().tween_property(brick, "rotation", sin(ang) * 0.35, UiKit.motion(0.4)) + + +# 坐砖掸灰(失败 §2.5——暖灰尘絮自棋盘中带缓缓飘落,最静一拍的运动承载) +func _play_dust_sadle() -> void: + _fx_dust_motes = 8 + var base := _board_rect.get_center() if _board_rect.size != Vector2.ZERO else get_viewport_rect().size * 0.5 + for i in 8: + var mote := ColorRect.new() + mote.color = Color(UiKit.WARM_FAINT, 0.85) + mote.size = Vector2(4, 4) + mote.mouse_filter = Control.MOUSE_FILTER_IGNORE + mote.position = base + Vector2((float(i % 4) - 1.5) * 30.0, -24.0 - 8.0 * float(i / 4)) + add_child(mote) + var mtw := mote.create_tween() + mtw.tween_interval(UiKit.motion(0.08 * i)) + mtw.tween_property(mote, "position:y", mote.position.y + 64.0, UiKit.motion(0.9)) + mtw.parallel().tween_property(mote, "modulate:a", 0.0, UiKit.motion(0.9)) + mtw.tween_callback(mote.queue_free) + + +func _clear_crest_bricks() -> void: + for b in _crest_bricks: + if is_instance_valid(b): + b.queue_free() + _crest_bricks.clear() + + +# 演出探针出口(fx_probe——胜利纹章/失败掸灰/旗光收回三断言面) +func fx_probe() -> Dictionary: + return {"crest_shards": _fx_crest_shards, "dust_motes": _fx_dust_motes, "flag_dots": _fx_flag_dots} diff --git a/game/presentation/views/legend_overlay.gd b/game/presentation/views/legend_overlay.gd index ff70e7fd..cb9592d2 100644 --- a/game/presentation/views/legend_overlay.gd +++ b/game/presentation/views/legend_overlay.gd @@ -12,7 +12,7 @@ signal closed const ENTRIES := [ ["#C9AF83", "完整覆尘地砖", "未开格——左键挖开(1AP,须相邻)· 右键插旗(1AP)"], ["#B39A78", "碎裂裸土", "已开 0 格——可通行,左键移动(免费)"], - ["#000000", "旗(封印印记)", "一次性承诺(不能拔)——轮末自动判定标中;插错可左键直接挖开(安全格零奖励、雷格吃挖雷惩罚)"], + ["#000000", "旗", "封印印记(呈现名·不能拔)——轮末自动判定标中;插错可左键直接挖开(安全格零奖励、雷格吃挖雷惩罚)"], ["#2e86de", "小人", "探险者——8 向相邻未开格常驻微光 = 可挖域"], ["#F0A828", "金徽章", "已开格奖励身份 icon(旗之下、数值之上,不进数字区)"], ["#33261B", "状态槽空态", "无状态效果时槽位隐藏,数据框上移补位——非异常"], diff --git a/game/presentation/views/settlement_fx_view.gd b/game/presentation/views/settlement_fx_view.gd index acd09a97..24e0c932 100644 --- a/game/presentation/views/settlement_fx_view.gd +++ b/game/presentation/views/settlement_fx_view.gd @@ -17,7 +17,8 @@ func _init() -> void: # 结算轮光带:金橙半透明横带自棋盘上缘扫至下缘(一次 0.5s,自动回收)—— -# "棋盘能量向战斗画面汇聚"的开轮语义(§2.2 色彩转金橙主导;区分于 board_fx 的完美排雷金光带) +# "棋盘能量向战斗画面汇聚"的开轮语义(§2.2 色彩转金橙主导;区分于 board_fx 的完美排雷金光带)。 +# 终稿(样张 v2 冻结批 T3):加 2px 亮金前导缘线——扫带运动方向可读(兑现光带逐行横扫 §2.2) func play_round_sweep(board_rect: Rect2) -> void: if board_rect.size == Vector2.ZERO: return @@ -27,10 +28,20 @@ func play_round_sweep(board_rect: Rect2) -> void: band.position = Vector2(board_rect.position.x, board_rect.position.y) band.size = Vector2(board_rect.size.x, 34.0) add_child(band) + var edge := ColorRect.new() + edge.color = Color(UiKit.GOLD_LIT, 0.7) + edge.mouse_filter = Control.MOUSE_FILTER_IGNORE + edge.size = Vector2(board_rect.size.x, 2.0) + edge.position = band.position + add_child(edge) var tw := band.create_tween() tw.tween_property(band, "position:y", board_rect.end.y - 34.0, UiKit.motion(0.5)).set_trans(Tween.TRANS_SINE) tw.parallel().tween_property(band, "modulate:a", 0.0, UiKit.motion(0.5)).set_delay(UiKit.motion(0.15)) tw.tween_callback(band.queue_free) + var etw := edge.create_tween() + etw.tween_property(edge, "position:y", board_rect.end.y - 2.0, UiKit.motion(0.5)).set_trans(Tween.TRANS_SINE) + etw.parallel().tween_property(edge, "modulate:a", 0.0, UiKit.motion(0.2)).set_delay(UiKit.motion(0.3)) + etw.tween_callback(edge.queue_free) # 跨区弹道:glyph 字形自 from 飞向 to(0.35s + 轻微上抛弧线 + 终点淡出,自动回收)—— diff --git a/game/presentation/views/standee_body.gd b/game/presentation/views/standee_body.gd index c5512344..a661e301 100644 --- a/game/presentation/views/standee_body.gd +++ b/game/presentation/views/standee_body.gd @@ -183,13 +183,22 @@ func set_pending(ptype: String, pid: String) -> void: _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 回退字形。 @@ -234,6 +243,7 @@ func mark_dead() -> void: 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) @@ -241,6 +251,38 @@ func mark_dead() -> void: slot_row.visible = false # 状态行随 mark_dead 同拍隐藏(批内代拍板②待追认) +# ---- 演出密度终稿(样张 v2 冻结批 T3——程序化 tween,零新帧资产) ---- + +const DEATH_SHARD_N := 5 # 碎块数(§8.4 冻结值) +var _death_shards := 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} + + func is_dead() -> bool: return _dead diff --git a/game/smoke/fx_probe.gd b/game/smoke/fx_probe.gd new file mode 100644 index 00000000..fa262006 --- /dev/null +++ b/game/smoke/fx_probe.gd @@ -0,0 +1,78 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# 演出密度探针(样张 v2 冻结批 T3——演出四项可触发+探针断言覆盖): +# ①死亡碎裂(standee mark_dead → 碎块计数)②意图三档动画(damage/buff/debuff/charge 摇定不炸+徽标可见) +# ③胜利纹章+失败掸灰+旗光收回(battle_end open_victory/open_defeat → fx_probe 计数) +# ④结算轮光带(settlement_fx play_round_sweep → 子节点落位)。 +# 运行:Godot_console.exe --headless --path . res://smoke/fx_probe.tscn +extends Node + + +func _ready() -> void: + await get_tree().process_frame + var problems: Array = [] + + # ---- ①② 站桩侧:死亡碎裂 + 意图三档 ---- + var sb_script := load("res://presentation/views/standee_body.gd") + var sb = sb_script.new("enemy") + sb.box = Vector2(150, 176) # 站桩盒几何(Node2D 自定义字段——非 Control.size) + add_child(sb) + sb.set_data({"display_name": "地精", "enemy_id": "goblin", "hp": 10, "max_hp": 10}) + await get_tree().process_frame + # 意图三档:特殊准备(damage/buff/debuff)与蓄力档(heavy)摇定——动画触发+徽标可见 + for tier in [["damage", "slash"], ["buff", "atk_up"], ["debuff", "weaken_hit"], ["damage", "heavy"]]: + sb.set_pending(tier[0], tier[1]) + if not sb.intent_probe().visible: + problems.append("意图徽标应可见(%s/%s)" % [tier[0], tier[1]]) + await get_tree().process_frame + sb.set_pending("basic", "") # 档①普通攻击=零表示 + if sb.intent_probe().visible: + problems.append("普通攻击徽标应零表示(§4.6.4 档①)") + # 死亡碎裂 + sb.mark_dead() + await get_tree().process_frame + var dp: Dictionary = sb.fx_probe() + if int(dp.get("death_shards", 0)) != 5: + problems.append("死亡碎块应 5 块(§8.4 冻结值),实得 %s" % [dp.get("death_shards")]) + if not sb.is_dead(): + problems.append("mark_dead 后 is_dead 应真") + + # ---- ③ 终局侧:胜利纹章 / 失败掸灰+旗光 ---- + var end_ov := BattleEndOverlay.new() + add_child(end_ov) + var br := Rect2(300, 300, 400, 300) + end_ov.open_victory("胜利", "继续", br) + await get_tree().process_frame + var vp: Dictionary = end_ov.fx_probe() + if int(vp.get("crest_shards", 0)) != 10: + problems.append("胜利纹章碎砖应 10 枚(§2.4),实得 %s" % [vp.get("crest_shards")]) + end_ov.close() + end_ov.open_defeat("失败", "重来", br) + await get_tree().process_frame + var fp: Dictionary = end_ov.fx_probe() + if int(fp.get("dust_motes", 0)) != 8: + problems.append("失败掸灰尘絮应 8 枚(§2.5),实得 %s" % [fp.get("dust_motes")]) + if int(fp.get("flag_dots", 0)) != 8: + problems.append("旗光收回应 8 点,实得 %s" % [fp.get("flag_dots")]) + end_ov.close() + + # ---- ④ 结算轮光带(含前导亮缘) ---- + var sfx := SettlementFxView.new() + add_child(sfx) + sfx.play_round_sweep(br) # 光带几何全由 board_rect 驱动——无需容器尺寸 + await get_tree().process_frame + var n_fx := 0 + for ch in sfx.get_children(): + if ch is ColorRect: + n_fx += 1 + if n_fx < 2: + problems.append("结算光带应含带体+前导缘线 ≥2 节点,实得 %d" % n_fx) + + # ---- 汇总 ---- + if problems.is_empty(): + print("[fx_probe] PASS — 死亡碎裂5块/意图三档+零表示/胜利纹章10/失败掸灰8+旗光8/结算光带+缘线 全链路通过") + get_tree().quit(0) + else: + for p in problems: + print("[fx_probe] FAIL — " + p) + print("[fx_probe] FAIL — %d 项断言未过" % problems.size()) + get_tree().quit(1) diff --git a/game/smoke/fx_probe.tscn b/game/smoke/fx_probe.tscn new file mode 100644 index 00000000..b52cc316 --- /dev/null +++ b/game/smoke/fx_probe.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://smoke/fx_probe.gd" id="1"] + +[node name="FxProbe" type="Node"] +script = ExtResource("1")