659 lines
30 KiB
GDScript
659 lines
30 KiB
GDScript
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||
# 实现 GDD:design/ux/battle.md(战斗屏 D 区 BoardView——格态/悬停预承诺光标/可挖域呼吸微光/
|
||
# BFS 路径预览/奖励徽章/结算期去饱和锁输入)。
|
||
# Date: 2026-09-12(battle.md 战斗屏改造批;基础 = 2026-09-11 HUD框架-lite 版)
|
||
# 棋盘 Node2D 迁移批 Phase A 2026-09-23:渲染本体由「GridContainer + 逐格 Button」平移为
|
||
# 「Control 壳 + SubViewportContainer + SubViewport + BoardWorld(Node2D) + CellUnit(Node2D)」
|
||
# (ADR-0006 / docs/architecture/board-node2d-migration-plan.md §2)。对外契约逐项保持:
|
||
# 类名/extends/三信号/click_cell/_cells 键面(Vector2i→格单元,值 Button→CellUnit)/
|
||
# board()/set_desaturated()/set_identity_resolver()/set_variant_mask_resolver()/set_cell_area()/
|
||
# rebuild()/refresh()/set_dig_glow_enabled()——探针与组合根零改造。
|
||
# 命中 = 壳层 _gui_input + 板级公式单点换算(ADR-0006 Decision 3,_cell_at 为唯一公式函数;
|
||
# 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 定长不增删,
|
||
# 重建仅在换板时发生)。
|
||
class_name BoardView
|
||
extends Control
|
||
|
||
signal action_requested(cell: Vector2i, kind: String) # kind ∈ {dig, move, flag_place, flag_pull}
|
||
signal hover_forbidden(cell: Vector2i) # 非相邻未开格悬停(首遇 toast 归组合根去重)
|
||
signal chord_attempted(cell: Vector2i) # 双击快开尝试(chord 肌肉记忆提示锚)
|
||
|
||
const _CELL_UNIT := preload("res://presentation/views/board_cell_unit.gd")
|
||
const _BOARD_WORLD := preload("res://presentation/views/board_world.gd")
|
||
|
||
const COL_BRICK := UiKit.BRICK # 未开——掩尘完整地砖(色板原语 #C9AF83)
|
||
const COL_FLOOR := UiKit.FLOOR # 已开——裸土 #B39A78(碎裂裸露地面)
|
||
const COL_DIGIT := UiKit.INK # 数字——墨族 #33261B(对格底 ≥4.5:1 硬指标)
|
||
const COL_HERO := UiKit.HERO_BLUE # 小人边框——小人蓝 #2E6E8E
|
||
const COL_FLAG := UiKit.FLAG_RED # 旗朱 #C63D22
|
||
const COL_GOLD := UiKit.GOLD # 奖励金 #F0A828
|
||
const COL_PATH := UiKit.HERO_BLUE # 路径预览点迹——小人蓝细线
|
||
const COL_DIG_EDGE := UiKit.DIG_EDGE # 可挖域呼吸微光基色(绿相,明度贴格底)
|
||
const COL_HL_TARGET := UiKit.GOLD # 悬停目标格边框(金)
|
||
const REWARD_BADGE := { # 已开格格面资源身份 icon(cell_type 九型键;极性视觉归批 2)
|
||
"attack": "攻", "defense": "守", "heal": "医", "blast": "爆", "weaken": "弱", "pierce": "穿", "magic": "魔", # 穿/魔 = 伤害道具批 2026-09-18
|
||
"action": "行", # 行 = 行动点型(八型批 2026-09-20——ap_up/ap_drain 两行;徽章键漏网补齐,ui_click_probe 91 断言修复)
|
||
"gp": "金", # 金 = GP 型(GP+商店房实施批 2026-09-21——gp_gain/gp_loss 两行;金色系 ~40° 相位,禁入带紫赤 300–345° 外,取色 UiKit.GOLD 徽标色单点)
|
||
}
|
||
const BADGE_PLACEHOLDER := "?" # 未知图标键占位纹样(HUD框架:禁静默空图标)
|
||
const CURSOR_DIG := Input.CURSOR_CROSS # 挖——镐形自绘纹理载体
|
||
const CURSOR_FLAG := Input.CURSOR_POINTING_HAND # 旗——旗形自绘纹理载体
|
||
const CURSOR_MOVE_SEM := Input.CURSOR_ARROW # 移动——箭头(命名避开 Control.CURSOR_MOVE 原生成员遮蔽)
|
||
const CHORD_WINDOW_MS := 350
|
||
const SEP := 2 # 格间距(旧 GridContainer v/h_separation 同值——格尺寸公式与命中换算共用)
|
||
|
||
# 棋盘立体参数(2026-09-24 用户实机三档对比拍板「中档」定稿——原型 F9/按钮入口已退役;
|
||
# §1.4 注记待 art-bible 并行批落籍后补写)
|
||
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_SPRITE_SCALE := 1.5 # 1.5× 档(2026-09-26 用户二裁:实机看 1× 偏小放大 1.5 倍——明示
|
||
# 裁决压圣经 §8.2「禁非整数缩放」一般规则,§8.2 豁免注记归 art-bible
|
||
# 批另落;头可越格上沿不裁剪)
|
||
const HERO_GROUND_FRAC := 0.25 # 脚锚纵向落点 = 格底往上 25%(用户口径「脚踩格子 1/4 处·从下往上数」;
|
||
# 头越上格沿造立体感。⚠ 参照系纪律:本值一律自底边起算——上一批用
|
||
# 「自顶 75%」表述同线异名,措辞口径误导用户被纠,本批统一为自底边)
|
||
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 脚底收敛)
|
||
const HERO_SHADOW_PX := Vector2(32, 12) # 脚下椭圆影屏幕尺寸(Godot 组件合成——径向渐变纹理压扁,
|
||
# 贴图零改动,2026-09-26 用户裁决走引擎侧)
|
||
|
||
var _wrap: CenterContainer
|
||
var _svp_container: SubViewportContainer
|
||
var _viewport: SubViewport
|
||
var _world: Node2D # BoardWorld(preload 脚本实例——Node2D 世界根/材质挂点)
|
||
var _cells := {} # Vector2i → CellUnit(Node2D)——键面同构(探针断言面)
|
||
var _board: BoardData = null
|
||
var _minesweeper = null # 排雷玩法 只读查询引用(refresh 更新;null = 无战斗)
|
||
var _identity_cb := Callable() # 地图战斗资源 身份解析回调(组合根注入只读面)——Vector2i → Dictionary/null
|
||
var _mask_cb := Callable() # 地形变异 数字遮蔽回调(组合根注入只读面)——Vector2i → bool(true = 该格数字不显示)
|
||
var _diggable := {} # 可挖域(8 向相邻未开格)——呼吸微光作用集
|
||
var _breath_t := 0.0
|
||
var _dig_glow := false # 挖掘期才允许唯一在动元素(art-bible §2.1)
|
||
var _hover := Vector2i(-9, -9)
|
||
var _last_click := {"cell": Vector2i(-9, -9), "ms": -100000}
|
||
var _r0 := {} # 出生展开区 R₀(地图战斗资源 mapping 资格域 = S∖R₀——徽章查询跳过,防 E19 诊断噪音)
|
||
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
|
||
|
||
|
||
func _init() -> void:
|
||
size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
_wrap = CenterContainer.new()
|
||
_wrap.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
_wrap.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(_wrap)
|
||
_svp_container = SubViewportContainer.new()
|
||
_svp_container.stretch = true # 容器尺寸 = 视口逻辑分辨率(_update_world_size 同步)——恒 1:1 无缩放
|
||
_svp_container.mouse_filter = Control.MOUSE_FILTER_IGNORE # 输入归壳层 _gui_input(板级公式换算),不进 SubViewport
|
||
_wrap.add_child(_svp_container)
|
||
_viewport = SubViewport.new()
|
||
_viewport.transparent_bg = true # 格间距露出 D 区底色(与旧 Button 网格间隙同观感)
|
||
_svp_container.add_child(_viewport)
|
||
_world = _BOARD_WORLD.new()
|
||
_viewport.add_child(_world)
|
||
_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:
|
||
return _board
|
||
|
||
|
||
func _ready() -> void:
|
||
_apply_custom_cursors()
|
||
mouse_exited.connect(_on_shell_mouse_exited)
|
||
|
||
|
||
# 结算期/道具窗口态:D 区去饱和 + 金橙偏移锁输入(明度不变——art-bible §2);
|
||
# 材质挂 BoardWorld 世界级单点(CellUnit use_parent_material 链下随世界灰化——语义与旧逐格挂材质一致)
|
||
func set_desaturated(on: bool) -> void:
|
||
if _desat_tween != null and _desat_tween.is_valid():
|
||
_desat_tween.kill()
|
||
_desat_tween = create_tween()
|
||
var target := 1.0 if on else 0.0
|
||
_desat_tween.tween_method(func(v: float): _desat_mat.set_shader_parameter("strength", v),
|
||
float(_desat_mat.get_shader_parameter("strength")), target, UiKit.motion(0.15))
|
||
_world.material = _desat_mat if on else null
|
||
|
||
|
||
# 组合根注入 地图战斗资源 身份解析只读回调(query_identity)——奖励徽章数据源
|
||
func set_identity_resolver(cb: Callable) -> void:
|
||
_identity_cb = cb
|
||
|
||
|
||
# 组合根注入 地形变异 数字遮蔽只读回调(信息型 hide_number 触发面——触发枚举 on_number_shown:
|
||
# 已开且显示数字时起作用;读取方按格子变异数据行动,规则本体零特判)
|
||
func set_variant_mask_resolver(cb: Callable) -> void:
|
||
_mask_cb = cb
|
||
|
||
|
||
# SubViewport 主题/字体显式注入挂点(Phase B 两舞台复用——SubViewport 不自动继承主场景 Control 主题;
|
||
# 本项目无自定义主题,缺省 = 引擎回退字体,与旧 Button 渲染同源)
|
||
func set_world_font(f: Font) -> void:
|
||
_world.board_font = f
|
||
for c in _cells:
|
||
_cells[c].font = f
|
||
_cells[c].queue_redraw()
|
||
|
||
|
||
# D 区可用面积(AP 条之外)→ 格边长 = floor((min(宽, 高) − 预留 − (N−1)×格间距) / N)(battle.md 格尺寸公式)
|
||
# 2026-09-19 修复:格边长公式补算 (N−1)×2px 网格间距 + 垂直预留 24px——旧公式只算 N×cell,
|
||
# L2 12×12 板网格实高溢出可用区 ~6px,下缘被 clip_contents 裁掉约 1/6 格高。
|
||
func set_cell_area(avail: Vector2) -> void:
|
||
if _board == null or _board.width == 0:
|
||
return
|
||
var n: int = maxi(_board.width, _board.height)
|
||
var sep := SEP # 格间距(_init 同值)
|
||
var d := DEPTH_PX
|
||
var reserve := 24.0 + d # 垂直总预留(上 12 + 下 12 + 悬崖外延——悬崖占预算防溢出)
|
||
var cell := int(floor((minf(avail.x - 12.0 - d, avail.y - reserve) - float((n - 1) * sep)) / float(n)))
|
||
_cell = float(clampi(cell, 26, 46))
|
||
var stride := _cell + float(SEP)
|
||
for c in _cells:
|
||
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()
|
||
|
||
|
||
# 悬崖参数下发(网格像素尺寸/步距/列数/材质锚色——BoardWorld._draw 消费)
|
||
func _refresh_slab() -> void:
|
||
if _board == null or _board.width == 0:
|
||
return
|
||
_world.depth_px = DEPTH_PX
|
||
_world.slab = Vector2(_board.width, _board.height) * (_cell + float(SEP)) - Vector2(SEP, SEP)
|
||
_world.stride_px = _cell + float(SEP)
|
||
_world.cols = _board.width
|
||
_world.mat = COL_BRICK
|
||
|
||
|
||
# 战斗启动/换板:按板尺寸重建格单元(minesweeper 引用随之更新)
|
||
func rebuild(board: BoardData, minesweeper) -> void:
|
||
_board = board
|
||
_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():
|
||
_r0[c] = true
|
||
if _board == null:
|
||
return
|
||
var stride := _cell + float(SEP)
|
||
for y in _board.height:
|
||
for x in _board.width:
|
||
var c := Vector2i(x, y)
|
||
var u: Node2D = _CELL_UNIT.new()
|
||
u.cell = c
|
||
u.edge = _cell
|
||
u.position = Vector2(c) * stride
|
||
u.bevel = DEPTH_BEVEL # 定稿倒角——新建格单元继承
|
||
u.use_parent_material = true # 去饱和材质挂 BoardWorld——单元随世界材质灰化
|
||
u.font = _world.board_font
|
||
_world.add_child(u)
|
||
_cells[c] = u
|
||
set_cell_area(size)
|
||
refresh()
|
||
|
||
|
||
# 逐格重绘(挖掘/结算后调用)
|
||
func refresh() -> void:
|
||
if _board == null or _minesweeper == null:
|
||
return
|
||
var hero: Vector2i = _minesweeper.hero_pos()
|
||
_diggable.clear()
|
||
for n in _board.neighbors8(hero):
|
||
if not _minesweeper.is_revealed(n):
|
||
_diggable[n] = true
|
||
for y in _board.height:
|
||
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, HERO_SHADOW_PX, HERO_SPRITE_SCALE)
|
||
sp.scale = Vector2.ONE * HERO_SPRITE_SCALE
|
||
sp.position = _hero_feet(hero)
|
||
_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 := HERO_SPRITE_SCALE
|
||
var feet := _hero_feet(to)
|
||
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 := HERO_SPRITE_SCALE
|
||
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))
|
||
|
||
|
||
# 英雄脚底锚点——**世界坐标**(精灵在 SubViewport 世界内:格原点 = 格×步距,禁带壳层居中偏移。
|
||
# 2026-09-26 修正:上批误用壳层 cell_origin(含 _svp_container.position 居中偏移)定位世界内精灵,
|
||
# 实测整体右偏 ~7 格+偏离格心——实机截图用户报缺陷)。
|
||
# 纵向 = 格底往上 25% 线(脚踩格子 1/4 处——2026-09-26 用户裁决);横向格中线。身体向上延伸,
|
||
# 头越上格沿不裁剪(立体感裁决);锚点与精灵缩放解耦(缩放只改身体向上长多少)。
|
||
func _hero_feet(c: Vector2i) -> Vector2:
|
||
return Vector2(c) * (_cell + float(SEP)) + Vector2(_cell * 0.5, _cell * (1.0 - HERO_GROUND_FRAC))
|
||
|
||
|
||
# 英雄持握手位(fx 同层坐标系 = 壳层坐标——fx 在 SubViewport 之外,须含 _svp_container.position
|
||
# 居中偏移:世界脚底锚 + 壳层偏移即得)。手 = 盒底往上 1/4 盒高、水平居中(2026-09-25 用户裁决;
|
||
# 盒高随 HERO_SPRITE_SCALE——手位钉在身体上不随缩放漂移)
|
||
func hero_hand_point() -> Vector2:
|
||
return _svp_container.position + _hero_feet(_hero_last) \
|
||
+ Vector2(0.0, -HERO_FRAME_PX * 0.25 * HERO_SPRITE_SCALE)
|
||
|
||
|
||
# 英雄精灵缩放档(fx 挥铲同档出口——两演出比例恒一致)
|
||
func hero_sprite_scale() -> float:
|
||
return HERO_SPRITE_SCALE
|
||
|
||
|
||
# 真实输入路径(行为探针复用):合成点击 → 与真实 gui_input 同一翻译出口
|
||
func click_cell(cell: Vector2i, button: int) -> void:
|
||
var ev := InputEventMouseButton.new()
|
||
ev.button_index = button
|
||
ev.pressed = true
|
||
_on_cell_input(cell, ev)
|
||
|
||
|
||
# 板级公式单点(ADR-0006 Decision 3):格坐标 = floor(板内局部坐标 / 格步长)
|
||
#(格步长 = 格边长 + 间距;板外/负域返回 (-9,-9)。旧逐格 Button 命中被此函数取代;
|
||
# 2px 格间隙落入左/上邻格——旧间隙为零命中死区,等价性偏差已登记迁移方案汇报面)
|
||
func _cell_at(shell_pos: Vector2) -> Vector2i:
|
||
if _board == null or _board.width == 0:
|
||
return Vector2i(-9, -9)
|
||
var p := shell_pos - _svp_container.position # CenterContainer 居中偏移(wrap 恒在壳 (0,0)——容器位置即棋盘原点)
|
||
if p.x < 0.0 or p.y < 0.0:
|
||
return Vector2i(-9, -9)
|
||
var stride := _cell + float(SEP)
|
||
var c := Vector2i(int(floor(p.x / stride)), int(floor(p.y / stride)))
|
||
if c.x >= _board.width or c.y >= _board.height:
|
||
return Vector2i(-9, -9)
|
||
return c
|
||
|
||
|
||
# 格中心(壳内局部坐标)——ui_input_probe 合成事件落点出口(与 _cell_at/cell_origin 同一公式族)
|
||
func cell_center_local(c: Vector2i) -> Vector2:
|
||
return cell_origin(c) + Vector2.ONE * (_cell * 0.5)
|
||
|
||
|
||
# 视口逻辑分辨率 = 板像素尺寸 + 悬崖外延(stretch=true 且容器 min 尺寸同步——缩放比恒 1:1)
|
||
func _update_world_size() -> void:
|
||
if _board == null or _board.width == 0:
|
||
return
|
||
var d := DEPTH_PX
|
||
var px := Vector2(_board.width, _board.height) * (_cell + float(SEP)) - Vector2(SEP, SEP) + Vector2(d, d)
|
||
_svp_container.custom_minimum_size = px
|
||
# 树内 stretch=true 时 SubViewport 尺寸由容器接管(手设被引擎拒绝并刷警告);树外(冒烟直建)才需手设保 1:1
|
||
if not _svp_container.is_inside_tree():
|
||
_viewport.size = px
|
||
|
||
|
||
# 真实输入路径(ADR-0006 Decision 3):壳层 _gui_input → 板级公式换算 → 复用既有 hover/点选/chord 出口
|
||
func _gui_input(ev: InputEvent) -> void:
|
||
if _minesweeper == null:
|
||
return
|
||
if ev is InputEventMouseMotion:
|
||
_hover_at(ev.position)
|
||
elif ev is InputEventMouseButton and ev.pressed:
|
||
var c := _cell_at(ev.position)
|
||
if c.x >= 0:
|
||
_on_cell_input(c, ev)
|
||
|
||
|
||
func _hover_at(pos: Vector2) -> void:
|
||
var c := _cell_at(pos)
|
||
if c.x < 0:
|
||
if _hover.x >= 0:
|
||
_on_cell_unhover(_hover)
|
||
elif c != _hover:
|
||
if _hover.x >= 0:
|
||
_on_cell_unhover(_hover)
|
||
_on_cell_hover(c)
|
||
|
||
|
||
func _on_shell_mouse_exited() -> void:
|
||
if _hover.x >= 0:
|
||
_on_cell_unhover(_hover)
|
||
|
||
|
||
func _on_cell_input(c: Vector2i, ev: InputEvent) -> void:
|
||
if _minesweeper == null or not (ev is InputEventMouseButton and ev.pressed):
|
||
return
|
||
if ev.button_index == MOUSE_BUTTON_LEFT:
|
||
if _minesweeper.is_revealed(c):
|
||
action_requested.emit(c, "move")
|
||
else:
|
||
action_requested.emit(c, "dig")
|
||
_detect_chord(c)
|
||
elif ev.button_index == MOUSE_BUTTON_RIGHT:
|
||
if not _minesweeper.has_flag(c):
|
||
action_requested.emit(c, "flag_place")
|
||
# 旗 = 一次性承诺(插旗不可逆批 2026-09-19):拔旗退役——旗格右键零动作;
|
||
# 左键相邻旗格直接挖(四情况②④兑付改判,规则引擎放行)。
|
||
|
||
|
||
# chord 肌肉记忆首遇锚:同格双左击快开尝试(350ms 窗)——每 run 一次提示由组合根去重
|
||
func _detect_chord(c: Vector2i) -> void:
|
||
var now := Time.get_ticks_msec()
|
||
if _last_click.cell == c and now - int(_last_click.ms) <= CHORD_WINDOW_MS:
|
||
chord_attempted.emit(c)
|
||
_last_click.ms = -100000
|
||
return
|
||
_last_click = {"cell": c, "ms": now}
|
||
|
||
|
||
# ---- 悬停预承诺(pattern 4):三语义光标 + 目标格边框 + BFS 路径预览 ----
|
||
|
||
func _on_cell_hover(c: Vector2i) -> void:
|
||
_hover = c
|
||
_apply_hover_visuals(c)
|
||
|
||
|
||
func _on_cell_unhover(c: Vector2i) -> void:
|
||
if _hover == c:
|
||
_hover = Vector2i(-9, -9)
|
||
_clear_hover_visuals(c)
|
||
|
||
|
||
func _refresh_hover_after_paint() -> void:
|
||
# 状态随 refresh 变化后光标/边框语义需重算(悬停格四格态可能已变)
|
||
if _hover.x >= 0 and _cells.has(_hover):
|
||
_apply_hover_visuals(_hover)
|
||
|
||
|
||
func _apply_hover_visuals(c: Vector2i) -> void:
|
||
if _minesweeper == null or not _cells.has(c):
|
||
return
|
||
_clear_hover_visuals(c)
|
||
var u: Node2D = _cells[c]
|
||
var revealed: bool = _minesweeper.is_revealed(c)
|
||
var flag: bool = _minesweeper.has_flag(c)
|
||
var adjacent: bool = _diggable.has(c)
|
||
if revealed:
|
||
u.hl_visible = true
|
||
u.hl_color = COL_HL_TARGET # 移动目标格边框(金)
|
||
_set_cursor_shape(c, CURSOR_MOVE_SEM)
|
||
_show_path_preview(c)
|
||
elif adjacent:
|
||
u.hl_visible = true
|
||
u.hl_color = COL_HL_TARGET # 挖掘目标格边框(相邻旗格同此——左键直接挖,插旗不可逆批)
|
||
_set_cursor_shape(c, CURSOR_DIG)
|
||
elif flag:
|
||
u.hl_visible = true
|
||
u.hl_color = COL_FLAG # 已承诺旗标记(旗朱;无交互可供——拔旗已退役)
|
||
_set_cursor_shape(c, Input.CURSOR_FORBIDDEN)
|
||
else:
|
||
u.hl_visible = true
|
||
u.hl_color = UiKit.RED_FORBID # 不可及域灰禁框(禁止光标+首遇 toast 双通道)
|
||
_set_cursor_shape(c, Input.CURSOR_FORBIDDEN)
|
||
hover_forbidden.emit(c)
|
||
|
||
|
||
func _clear_hover_visuals(c: Vector2i) -> void:
|
||
if not _cells.has(c):
|
||
return
|
||
_cells[c].hl_visible = false
|
||
_hide_path_preview()
|
||
|
||
|
||
func _set_cursor_shape(c: Vector2i, shape: int) -> void:
|
||
mouse_default_cursor_shape = shape # 壳级光标(悬停格语义决定——单格悬停恒一,等价旧逐格光标)
|
||
|
||
|
||
func _show_path_preview(c: Vector2i) -> void:
|
||
# D3 合法性指示:悬停已开格 → BFS 路径蓝细线点迹(compute_move_path 只读面)
|
||
if _minesweeper == null:
|
||
return
|
||
var path: Array = _minesweeper.compute_move_path(c)
|
||
for pc in path:
|
||
if _cells.has(pc):
|
||
_cells[pc].dot_visible = true
|
||
|
||
|
||
func _hide_path_preview() -> void:
|
||
for c in _cells:
|
||
_cells[c].dot_visible = false
|
||
|
||
|
||
# ---- 奖励徽章(已开格身份 icon——旗之下、纹带之上、不进数字区) ----
|
||
|
||
func _update_badge(c: Vector2i) -> String:
|
||
if _minesweeper == null or not _minesweeper.is_revealed(c) or not _identity_cb.is_valid():
|
||
return ""
|
||
if _r0.has(c):
|
||
return "" # R₀ 初始展开格无奖励身份(地图战斗资源 资格域 = S∖R₀)
|
||
var rec: Dictionary = _minesweeper.reveal_record(c) # E2 收口:已开记录经查询面(禁直读 排雷玩法 状态字典)
|
||
if rec.get("kind", "") != "safe":
|
||
return ""
|
||
var ident = _identity_cb.call(c)
|
||
if not (ident is Dictionary):
|
||
return ""
|
||
return _badge_glyph(String(ident.get("cell_type", "")))
|
||
|
||
|
||
# 奖励图标键解析(HUD框架:查不到的键立即断言 + ? 占位纹样 + 诊断;键集 = 格面资源 cell_type 九值枚举——GP+商店房实施批 2026-09-21 八→九)
|
||
func _badge_glyph(category: String) -> String:
|
||
if REWARD_BADGE.has(category):
|
||
return REWARD_BADGE[category]
|
||
push_error("[棋盘] 未知奖励图标键(映射表外 category——立即断言): %s" % category)
|
||
assert(false, "未知奖励图标键: " + category)
|
||
return BADGE_PLACEHOLDER
|
||
|
||
|
||
# ---- 格态渲染(灰度截图可辨:完整覆尘 / 碎裂裸土 / 已开数字) ----
|
||
|
||
func _paint_cell(c: Vector2i, hero: Vector2i) -> void:
|
||
var u: Node2D = _cells.get(c)
|
||
if u == null:
|
||
return
|
||
var revealed: bool = _minesweeper.is_revealed(c)
|
||
var flag: bool = _minesweeper.has_flag(c)
|
||
var is_hero: bool = c == hero
|
||
var glyph := ""
|
||
var bg: Color
|
||
var digit_col := COL_DIGIT
|
||
if revealed:
|
||
var rec: Dictionary = _minesweeper.reveal_record(c) # E2 收口:已开记录经查询面
|
||
if rec.get("kind", "") == "mine":
|
||
bg = COL_FLOOR
|
||
glyph = "雷"
|
||
digit_col = UiKit.RED_MINE
|
||
else:
|
||
bg = COL_FLOOR
|
||
var d: int = rec.get("digit", 0)
|
||
if d > 0:
|
||
if _mask_cb.is_valid() and bool(_mask_cb.call(c)):
|
||
glyph = "?" # 数字遮蔽(地形变异 hide_number——信息型:该格真实数字不显示)
|
||
else:
|
||
glyph = str(d)
|
||
else:
|
||
if flag:
|
||
bg = COL_BRICK
|
||
glyph = "旗"
|
||
digit_col = COL_FLAG
|
||
else:
|
||
bg = COL_BRICK
|
||
var sb := UiKit.panel_flat(bg, bg.darkened(0.15), 3)
|
||
if is_hero:
|
||
sb.set_border_width_all(3)
|
||
sb.border_color = COL_HERO
|
||
if glyph == "" and _hero_tex_idle == null:
|
||
# 贴图缺位回退字形「人」(项目纹理槽惯例——贴图在位=格内字形退役,单位本体由独立精灵承载)
|
||
glyph = "人"
|
||
digit_col = COL_HERO
|
||
u.bg_sb = sb
|
||
u.glyph = glyph
|
||
u.glyph_color = digit_col
|
||
u.badge_glyph = _update_badge(c)
|
||
u.queue_redraw()
|
||
|
||
|
||
# ---- 可挖域 8 向常驻呼吸微光(挖掘期唯一在动元素) ----
|
||
|
||
func set_dig_glow_enabled(on: bool) -> void:
|
||
_dig_glow = on
|
||
if not on:
|
||
_reset_breath()
|
||
|
||
|
||
func _process(delta: float) -> void:
|
||
if not _dig_glow or _diggable.is_empty():
|
||
return
|
||
_breath_t += delta
|
||
if UiKit.motion_scale < 1.0:
|
||
return # 减动效:呼吸停格(保留当前微光边框——静态可辨、无持续运动)
|
||
var k := 0.5 + 0.5 * sin(_breath_t * 3.0) # 3 rad/s≈0.48Hz——无 >3 次/秒频闪(AC 基线)
|
||
var col := COL_DIG_EDGE.lerp(UiKit.DIG_EDGE_DEEP, 0.55 - 0.35 * k)
|
||
for c in _diggable:
|
||
var u: Node2D = _cells.get(c)
|
||
if u == null or u.bg_sb == null:
|
||
continue
|
||
u.bg_sb.set_border_width_all(2)
|
||
u.bg_sb.border_color = col
|
||
u.queue_redraw()
|
||
|
||
|
||
func _reset_breath() -> void:
|
||
for c in _diggable:
|
||
var u: Node2D = _cells.get(c)
|
||
if u != null and u.bg_sb != null:
|
||
u.bg_sb.set_border_width_all(1)
|
||
u.bg_sb.border_color = u.bg_sb.bg_color.darkened(0.15)
|
||
u.queue_redraw()
|
||
|
||
|
||
# ---- 自绘光标纹理(镐/旗——引擎内置光标无此语义;程序化占位,headless 跳过) ----
|
||
|
||
func _apply_custom_cursors() -> void:
|
||
if DisplayServer.get_name() == "headless":
|
||
return
|
||
Input.set_custom_mouse_cursor(_make_cursor_texture("⛏"), Input.CURSOR_CROSS, Vector2(4, 4))
|
||
Input.set_custom_mouse_cursor(_make_cursor_texture("⚑"), Input.CURSOR_POINTING_HAND, Vector2(2, 2))
|
||
|
||
|
||
func _make_cursor_texture(glyph: String) -> ImageTexture:
|
||
var img := Image.create(24, 24, false, Image.FORMAT_RGBA8)
|
||
img.fill(Color(0, 0, 0, 0))
|
||
var f := ThemeDB.fallback_font
|
||
if f != null:
|
||
f.draw_string(img, Vector2(2, 18), glyph, HORIZONTAL_ALIGNMENT_LEFT, 20, 18, COL_DIGIT)
|
||
return ImageTexture.create_from_image(img)
|