diff --git a/game/presentation/main.gd b/game/presentation/main.gd index b8bc0204..c4d7d42c 100644 --- a/game/presentation/main.gd +++ b/game/presentation/main.gd @@ -685,6 +685,14 @@ func _on_board_action(cell: Vector2i, kind: String) -> void: # runner 置空,清图演出判定须先判 settlement(基线既有重入隐患,2026-09-13 加固) if accepted and kind == "dig" and settlement != null and settlement.minesweeper.current_board() != null and settlement.minesweeper.clear_map_cleared(): board_zone.fx.play_clear_burst() + if accepted and kind == "dig" and settlement != null and settlement.minesweeper != null: + # 挖掘演出(棋盘小人贴图批 2026-09-25):挥铲三帧 + 角色弹跳——受理后一拍触发(挖掘不移动英雄, + # 出手方向 = 目标格−英雄格;refresh 同拍重绘零冲突) + var hero_cell: Vector2i = settlement.minesweeper.hero_pos() + board_zone.fx.play_shovel_swing(board_view.cell_origin(hero_cell), cell - hero_cell, board_view.cell_size) + board_view.play_hero_dig_bounce() + elif accepted and kind == "move": + board_view.notify_hero_move() # 原地移动(目标=当前格幂等受理)帧闪回执——refresh diff 前置告知 board_view.refresh() _refresh_presentations() diff --git a/game/presentation/views/battle_stage_view.gd b/game/presentation/views/battle_stage_view.gd index bdc52fc5..b1c5a1f8 100644 --- a/game/presentation/views/battle_stage_view.gd +++ b/game/presentation/views/battle_stage_view.gd @@ -35,6 +35,7 @@ func _init(db_ref) -> void: vb.add_child(row) hero_standee = StandeeView.new("hero") + hero_standee.set_portrait_texture(UiKit.load_tex(UiKit.TEX_HERO_IDLE)) # 棋盘小人贴图批 2026-09-25(挂点现成——null 回退字形「人」;NEAREST 归 set_portrait_texture 单点) row.add_child(hero_standee) var center := CenterContainer.new() diff --git a/game/presentation/views/board_fx_view.gd b/game/presentation/views/board_fx_view.gd index 990082ef..12fdfa6f 100644 --- a/game/presentation/views/board_fx_view.gd +++ b/game/presentation/views/board_fx_view.gd @@ -120,6 +120,51 @@ func play_flag_hit_projectile(heavy: bool) -> void: tw.tween_callback(shot.queue_free) +# ---- 挖掘挥铲三帧(棋盘小人贴图批 2026-09-25——计划书 §3.C 解耦方案:铲归 fx 层、角色弹跳归 +# BoardView.play_hero_dig_bounce,两演出零共享属性) ---- + +const SHOVEL_FRAME_PX := 32.0 # 铲帧画布边长(缩放比与英雄帧同源) +const SHOVEL_FRAME_S := 0.08 # ~80ms/帧(f0→f1→f2 共 ~240ms 即收) +const SHOVEL_GRIP := Vector2(18, 12) # 握把锚点(素材侧锁定的画布坐标——出手位对齐点) + +var _shovel_texs: Array = [] # 惰性装载缓存(素材缺位 = 空表静默回退——素材不入 git) + + +# 挖掘挥铲:origin = 英雄格左上(board_area 坐标系——BoardView.cell_origin 出口,fx 与棋盘同层同系); +# dir = 挖掘方向单位格向量(素材铲头朝左——dir.x>0 翻转);edge = 格边长(缩放 = edge/32 与角色一致)。 +# 握把锚对齐英雄格与目标格共享边中点(出手位),铲头弧线自然落进目标格。 +func play_shovel_swing(origin: Vector2, dir: Vector2i, edge: float) -> void: + if _shovel_texs.is_empty(): + for p in ["res://assets/art/fx/shovel_swing_f0.png", + "res://assets/art/fx/shovel_swing_f1.png", + "res://assets/art/fx/shovel_swing_f2.png"]: + var t := UiKit.load_tex(p) + if t == null: + return # 素材缺位——静默回退(fx 层占位演出不设断言面) + _shovel_texs.append(t) + var s := edge / SHOVEL_FRAME_PX + var flip := dir.x > 0 + var grip := SHOVEL_GRIP + if flip: + grip.x = SHOVEL_FRAME_PX - SHOVEL_GRIP.x # flip_h 围绕纹理中心镜像——锚点 x 随镜 + var deliver := origin + Vector2(edge, edge) * 0.5 \ + + Vector2(dir) * (edge * 0.5 + edge * 0.12) # 共享边中点再向目标格偏 12% 格宽 + var sp := Sprite2D.new() + sp.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + sp.centered = true + sp.texture = _shovel_texs[0] + sp.flip_h = flip + sp.scale = Vector2.ONE * s + sp.position = deliver + (Vector2(SHOVEL_FRAME_PX, SHOVEL_FRAME_PX) * 0.5 - grip) * s + add_child(sp) + var tw := sp.create_tween() + for i in [1, 2]: + tw.tween_interval(UiKit.motion(SHOVEL_FRAME_S)) + tw.tween_callback(func() -> void: sp.texture = _shovel_texs[i]) + tw.tween_interval(UiKit.motion(SHOVEL_FRAME_S)) + tw.tween_callback(sp.queue_free) + + func _show_banner(text: String) -> void: _banner.text = text _banner.visible = true diff --git a/game/presentation/views/board_view.gd b/game/presentation/views/board_view.gd index 178e05b2..d10ba047 100644 --- a/game/presentation/views/board_view.gd +++ b/game/presentation/views/board_view.gd @@ -12,6 +12,9 @@ # SubViewportContainer 逻辑分辨率恒等于板像素尺寸(stretch 1:1,无缩放比),换算只需减居中偏移)。 # 程序化占位视觉等价平移(数字三档/旗/徽章/危险红/变异遮蔽 "?"/呼吸微光/悬停/路径预览/R₀ 豁免); # CellUnit 叠层 texture 槽位留好(本批不接纹理资产)。新增只读属性 cell_size(旧私有名 _cell 契约化出口)。 +# 棋盘小人贴图批 2026-09-25:英雄=BoardWorld 独立 Sprite2D(移动实体不进格面语义——计划书 §3.A; +# refresh diff last_hero_pos 驱动位移 Tween+站/走两帧+flip_h 朝向;素材缺位回退格内字形「人」); +# 挖掘=挥铲三帧特效(BoardFxView)+角色弹跳 Tween;格面五 texture 槽零改动。 # 高内聚边界:只读 排雷玩法 查询面(+地图战斗资源 身份解析回调)做渲染与意图识别,动作经信号外发——不调用核心写接口 # (settle_batch 归组合根编排),不持久持有战斗态(minesweeper 引用随 refresh 更新,仅作只读查询)。 # 行为等价:格件常驻、逐格重绘在 refresh 内完成,无布局回流(缺陷①回归约束——CellUnit 定长不增删, @@ -52,6 +55,13 @@ const SEP := 2 # 格间距(旧 GridContainer v/ const DEPTH_PX := 8.0 # 悬崖厚度(像素) const DEPTH_BEVEL := 0.6 # 顶面倒角强度 +# 英雄单位演出参数(棋盘小人贴图批 2026-09-25——计划书 §3.B/C 时长档) +const HERO_FRAME_PX := 32.0 # 英雄帧画布边长(素材 32×32 锁定——缩放比分母) +const HERO_MOVE_S := 0.14 # 位移时长(~140ms) +const HERO_STEP_S := 0.18 # 走路帧周期 = 原地撞墙帧闪时长(~180ms) +const HERO_DIG_S := 0.15 # 挖掘弹跳总时长(~150ms,半程压半程回) +const HERO_BOUNCE := 0.82 # 弹跳压缩比(scale.y 1→0.82→1 脚底收敛) + var _wrap: CenterContainer var _svp_container: SubViewportContainer var _viewport: SubViewport @@ -71,6 +81,16 @@ var _desat_mat: ShaderMaterial var _desat_tween: Tween var _cell := 46.0 # 当前格边长(set_cell_area 按容器尺寸重算——battle.md 格尺寸公式) +# ── 英雄单位精灵状态(棋盘小人贴图批 2026-09-25;素材 PNG 不入 git——load_tex 守卫装载,缺位=字形回退)── +var _hero_tex_idle: Texture2D # 站立帧(null = 素材缺位——零精灵,字形「人」原路径) +var _hero_tex_walk: Texture2D # 跨步帧 +var _hero_last := Vector2i(-99, -99) # 上拍英雄格(refresh diff 基准;哨兵 = 未落位/随 rebuild 复位) +var _hero_face_left := true # 朝向(素材只入库朝左帧——右向 = flip_h,计划书 §2 约定) +var _hero_bump_pending := false # 受理零位移移动回执(refresh diff 只认位置变化,原地移动须外部告知) +var _hero_move_tw: Tween +var _hero_bump_tw: Tween +var _hero_dig_tw: Tween + var cell_size: float: # 只读属性(ui_layout_probe 断言面——旧私有名 _cell 的契约化出口) get: return _cell @@ -95,6 +115,8 @@ func _init() -> void: _desat_mat = ShaderMaterial.new() _desat_mat.shader = load("res://presentation/views/desaturation.gdshader") _desat_mat.set_shader_parameter("strength", 0.0) + _hero_tex_idle = UiKit.load_tex(UiKit.TEX_HERO_IDLE) + _hero_tex_walk = UiKit.load_tex(UiKit.TEX_HERO_WALK) func board() -> BoardData: @@ -155,6 +177,8 @@ func set_cell_area(avail: Vector2) -> void: var u: Node2D = _cells[c] u.edge = _cell u.position = Vector2(c) * stride + if _world.hero != null and _hero_last.x >= 0: + _place_hero(_hero_last, _hero_last, true) # 格边长变更(窗口缩放)——英雄即时随格重置(杀位移 tween 防飞陈旧落点) _refresh_slab() _update_world_size() @@ -176,7 +200,9 @@ func rebuild(board: BoardData, minesweeper) -> void: _minesweeper = minesweeper for child in _world.get_children(): child.queue_free() + _world.hero = null # 英雄精灵随清场退役——下拍 refresh 经 ensure_hero 重建(首建即时落位) _cells.clear() + _hero_last = Vector2i(-99, -99) _r0.clear() _hover = Vector2i(-9, -9) for c in _board.compute_r0(): @@ -213,6 +239,100 @@ func refresh() -> void: for x in _board.width: _paint_cell(Vector2i(x, y), hero) _refresh_hover_after_paint() + _update_hero(hero) # 英雄精灵 diff 驱动(棋盘小人贴图批 2026-09-25) + + +# ---- 英雄单位精灵(棋盘小人贴图批 2026-09-25——计划书 §3.A/B;格面五 texture 槽零改动) ---- + +# refresh diff 驱动:位置变化 = 位移演出(140ms Tween + 走路帧);零位移但有受理移动回执 = 只播帧 +# 不位移(撞墙语义——排雷玩法「目标=当前格」幂等受理)。素材缺位 = 零精灵,字形「人」回退路径。 +func _update_hero(hero: Vector2i) -> void: + if _hero_tex_idle == null: + return + if _world.hero == null: + var sp: Sprite2D = _world.ensure_hero(_hero_tex_idle) + sp.scale = Vector2.ONE * (_cell / HERO_FRAME_PX) + sp.position = Vector2(hero) * (_cell + float(SEP)) + Vector2(_cell * 0.5, float(_cell)) + _hero_last = hero # 首建/随 rebuild 重建——即时落位无演出 + return + if hero == _hero_last and not _hero_bump_pending: + return + if hero != _hero_last and hero.x != _hero_last.x: + _hero_face_left = hero.x < _hero_last.x + _place_hero(_hero_last, hero, hero == _hero_last) + if hero == _hero_last: + _hero_frame_flash() + _hero_bump_pending = false + _hero_last = hero + + +# 英雄落位(instant = 即位无演出——首建/格边长变更;否则 140ms 位移 + 走路帧起手、到位回站帧。 +# 帧权威单一化:Sprite2D 换纹理路线(计划书 §3.B 二选一之后者)——换纹理+flip_h 同拍写定即生效, +# 无 AnimatedSprite2D 的 play()/advance(0) 错向面) +func _place_hero(from: Vector2i, to: Vector2i, instant: bool) -> void: + var sp: Sprite2D = _world.hero + if sp == null: + return + var s := _cell / HERO_FRAME_PX + var feet := Vector2(to) * (_cell + float(SEP)) + Vector2(_cell * 0.5, float(_cell)) + if _hero_move_tw != null and _hero_move_tw.is_valid(): + _hero_move_tw.kill() + if instant: + sp.scale = Vector2(s, s) + sp.position = feet + sp.texture = _hero_tex_idle + return + sp.scale = Vector2(s, s) # 位移重置弹跳残形(scale.y 由 play_hero_dig_bounce 独立回收) + sp.texture = _hero_tex_walk + sp.flip_h = not _hero_face_left # 素材基准朝左 + _hero_move_tw = create_tween() + _hero_move_tw.tween_property(sp, "position", feet, UiKit.motion(HERO_MOVE_S)).set_trans(Tween.TRANS_SINE) + _hero_move_tw.tween_callback(func() -> void: + if is_instance_valid(sp): + sp.texture = _hero_tex_idle) + + +# 原地移动帧闪(撞墙语义):跨步帧 ~180ms 后回站帧,零位移 +func _hero_frame_flash() -> void: + var sp: Sprite2D = _world.hero + if sp == null: + return + if _hero_bump_tw != null and _hero_bump_tw.is_valid(): + _hero_bump_tw.kill() + sp.texture = _hero_tex_walk + sp.flip_h = not _hero_face_left + _hero_bump_tw = create_tween() + _hero_bump_tw.tween_interval(UiKit.motion(HERO_STEP_S)) + _hero_bump_tw.tween_callback(func() -> void: + if is_instance_valid(sp): + sp.texture = _hero_tex_idle) + + +# 受理移动指令回执(组合根 settle 受理后、refresh 前调用)——原地移动经此触发帧闪; +# 目标异格时本旗标被 diff 位移路径自然吞掉(置位即清) +func notify_hero_move() -> void: + _hero_bump_pending = true + + +# 挖掘角色弹跳(计划书 §3.C:scale.y 1→0.82→1,脚底锚收敛不悬空;与位移/帧闪 tween 分开管理 +# 避免属性争抢——旧弹跳先 kill) +func play_hero_dig_bounce() -> void: + var sp: Sprite2D = _world.hero + if sp == null or _hero_tex_idle == null: + return + if _hero_dig_tw != null and _hero_dig_tw.is_valid(): + _hero_dig_tw.kill() + var s := _cell / HERO_FRAME_PX + sp.scale = Vector2(s, s) + _hero_dig_tw = create_tween() + _hero_dig_tw.tween_property(sp, "scale:y", s * HERO_BOUNCE, UiKit.motion(HERO_DIG_S * 0.5)).set_trans(Tween.TRANS_SINE) + _hero_dig_tw.tween_property(sp, "scale:y", s, UiKit.motion(HERO_DIG_S * 0.5)).set_trans(Tween.TRANS_SINE) + + +# 格左上角(board_area 全幅坐标系)——fx 同层演出定位出口(本视图与 fx 同为 board_area 全幅子件, +# 局部坐标同系;cell_center_local 同公式族) +func cell_origin(c: Vector2i) -> Vector2: + return _svp_container.position + Vector2(c) * (_cell + float(SEP)) # 真实输入路径(行为探针复用):合成点击 → 与真实 gui_input 同一翻译出口 @@ -239,9 +359,9 @@ func _cell_at(shell_pos: Vector2) -> Vector2i: return c -# 格中心(壳内局部坐标)——ui_input_probe 合成事件落点出口(与 _cell_at 同一公式族) +# 格中心(壳内局部坐标)——ui_input_probe 合成事件落点出口(与 _cell_at/cell_origin 同一公式族) func cell_center_local(c: Vector2i) -> Vector2: - return _svp_container.position + Vector2(c) * (_cell + float(SEP)) + Vector2.ONE * (_cell * 0.5) + return cell_origin(c) + Vector2.ONE * (_cell * 0.5) # 视口逻辑分辨率 = 板像素尺寸 + 悬崖外延(stretch=true 且容器 min 尺寸同步——缩放比恒 1:1) @@ -445,7 +565,8 @@ func _paint_cell(c: Vector2i, hero: Vector2i) -> void: if is_hero: sb.set_border_width_all(3) sb.border_color = COL_HERO - if glyph == "": + if glyph == "" and _hero_tex_idle == null: + # 贴图缺位回退字形「人」(项目纹理槽惯例——贴图在位=格内字形退役,单位本体由独立精灵承载) glyph = "人" digit_col = COL_HERO u.bg_sb = sb diff --git a/game/presentation/views/board_world.gd b/game/presentation/views/board_world.gd index 796c9d07..ac077d8f 100644 --- a/game/presentation/views/board_world.gd +++ b/game/presentation/views/board_world.gd @@ -11,6 +11,25 @@ extends Node2D var board_font: Font = null # 字体注入槽位(null = ThemeDB.fallback_font——与旧 Button 缺省主题字体同源) +var hero: Sprite2D = null # 英雄单位精灵(棋盘小人贴图批 2026-09-25——移动实体独立于 CellUnit 格面 + # 语义,计划书 §3.A;null = 未创建/随 rebuild 清场,BoardView.refresh 经 + # ensure_hero 重建。texture 缺位时 BoardView 不调本方法=字形「人」回退路径) + + +# 英雄精灵惰性创建(幂等):脚底锚(offset 上移半高——scale.y 弹跳向脚底收敛不悬空)+ z_index=1 +# 压格面之上(挥铲特效在 SubViewport 之外恒更上层,满足「格面之上、特效之下」叠序);NEAREST = +# SubViewport 缩放下像素清晰(默认 Linear 会糊)。返回值便于 BoardView 即时落位。 +func ensure_hero(tex: Texture2D) -> Sprite2D: + if hero != null: + return hero + hero = Sprite2D.new() + hero.texture = tex + hero.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + hero.centered = true + hero.offset = Vector2(0.0, -16.0) # 纹理中点上移半高——position = 脚底中点 + hero.z_index = 1 + add_child(hero) + return hero # ── 立体悬崖(BoardView.set_cell_area / _update_world_size 单点驱动;厚度定稿 8px=中档 2026-09-24)── var depth_px := 0.0: # 悬崖厚度(像素;0 = 平面) diff --git a/game/presentation/views/standee_view.gd b/game/presentation/views/standee_view.gd index 27441a9c..3c37528c 100644 --- a/game/presentation/views/standee_view.gd +++ b/game/presentation/views/standee_view.gd @@ -230,6 +230,7 @@ func set_portrait_texture(tex: Texture2D) -> void: _portrait_tex = TextureRect.new() _portrait_tex.expand_mode = TextureRect.EXPAND_IGNORE_SIZE _portrait_tex.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + _portrait_tex.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST # 32px 像素帧放大防糊(棋盘小人贴图批 2026-09-25) _portrait_tex.set_anchors_preset(Control.PRESET_FULL_RECT) _portrait_tex.offset_top = 44.0 _portrait_tex.offset_bottom = -26.0 diff --git a/game/presentation/views/ui_kit.gd b/game/presentation/views/ui_kit.gd index d5ca5be1..075ede83 100644 --- a/game/presentation/views/ui_kit.gd +++ b/game/presentation/views/ui_kit.gd @@ -74,6 +74,20 @@ static func motion(dur: float) -> float: return dur * motion_scale +# ── 单位/特效纹理路径单点(棋盘小人贴图批 2026-09-25)── +# 素材 PNG 不入 git(用户裁定)——装载必须经 load_tex(exists 守卫),缺位返回 null 走各消费点 +# 的字形/无演出回退路径;禁 preload(素材缺位树上直接断言炸脚本,冒烟安全红线) +const TEX_HERO_IDLE := "res://assets/art/character/character_hero_idle.png" +const TEX_HERO_WALK := "res://assets/art/character/character_hero_walk.png" + + +# 素材纹理守卫装载(exists 检查避开缺位树上 ResourceLoader 报错刷屏——冒烟/无素材检出静默回退) +static func load_tex(path: String) -> Texture2D: + if ResourceLoader.exists(path): + return load(path) + return null + + # ── StyleBox 工厂 ── # margin = (左, 上, 右, 下);border_w = 0 即无边框(根底/遮罩用) static func panel_flat(bg: Color, border: Color, corner: int = 0, border_w: int = 1, margin: Vector4 = Vector4.ZERO) -> StyleBoxFlat: