diff --git a/game/presentation/views/board_view.gd b/game/presentation/views/board_view.gd index 96870458..248a941b 100644 --- a/game/presentation/views/board_view.gd +++ b/game/presentation/views/board_view.gd @@ -10,6 +10,8 @@ signal chord_attempted(cell: Vector2i) ## 双击快开尝试( const _CELL_UNIT := preload("res://presentation/views/board_cell_unit.gd") const _BOARD_WORLD := preload("res://presentation/views/board_world.gd") +const _TILE_PALETTE := preload("res://presentation/views/tile_palette.gd") +const _HERO_SPRITE := preload("res://presentation/views/hero_sprite.gd") const COL_BRICK := UiKit.BRICK # 未开——掩尘完整地砖(色板原语 #C9AF83) const COL_FLOOR := UiKit.FLOOR # 已开——裸土 #B39A78(碎裂裸露地面) @@ -38,35 +40,11 @@ const DEPTH_BEVEL := 0.6 # 顶面倒角强度 # 立面/投影色参数在 CellUnit 常量——绘制侧单点) const WALL_PROJECTION_FRAC := 0.22 -# 英雄单位演出参数(计划书 §3.B/C 时长档) -const HERO_FRAME_PX := 32.0 # 英雄帧画布边长(素材 32×32 锁定——缩放比分母) -const HERO_SPRITE_SCALE := 1.5 # 1.5× 档(实机 1× 偏小故放大 1.5 倍——本档为圣经 §8.2「禁非整数缩放」 - # 一般规则的显式豁免;头可越格上沿不裁剪) -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 组件合成——径向渐变纹理压扁, - # 贴图零改动,走引擎侧) +# 英雄单位演出参数单点 = HeroSprite 常量族(本处只保留探针直读面别名,数值禁二写) +const HERO_SPRITE_SCALE := _HERO_SPRITE.SPRITE_SCALE # 1.5× 档(探针 case⑨/⑩ 直读面) +const HERO_GROUND_FRAC := _HERO_SPRITE.GROUND_FRAC # 脚锚纵向落点 25%(探针 case⑦/⑩ 直读面) -# ── 高墙/地面瓦片纹理路径单点 ── -# 素材 PNG 不入 git——一律 UiKit.load_tex(exists 守卫)装载,禁 preload; -# 缺位返回 null,整批任一缺位 = 弃用瓦片走程序化平色回退(BRICK/FLOOR,fresh checkout 冒烟不炸)。 -# 路径常量落本文件单点。词表 = -# design/research/board-tile-vocab-plan-a-2026-09-26.md(16 唯一墙瓦片 + 5 地面变体; -# 东/东北/东南/end_e/in_ne/in_se 六镜像件运行时 flip_x 合成,不出文件——词表 §二)。 -# 路径按 board_type 后缀参数化——terrain_<件名>_.png; -# 词表 §一 16 唯一墙瓦片名单单点在本常量数组。underdark/deep_underdark 两套 = -# tools/recolor_terrain_tiles.py 程序换色产;任一套缺位 → 整批回落 -# dungeon 套(禁同板混渲染);dungeon 套亦缺位 → 程序化平色回退(fresh checkout 冒烟不炸)。 -const TILE_WALL_NAMES := ["wall_center", "wall_center_crack", "wall_center_speckle", "wall_n", "wall_s", "wall_w", "wall_nw", "wall_sw", "wall_hrun", "wall_vrun", "wall_end_n", "wall_end_s", "wall_end_w", "wall_lone", "wall_in_nw", "wall_in_sw"] -const TILE_WALL_FMT := "res://assets/art/board/terrain_%s_%s.png" # %s=件名 %s=board_type -const TILE_SEAL_MARK := "res://assets/art/board/seal_mark.png" # 封印印记纹理(独立可选件) -const TILE_FLOOR_FMT := "res://assets/art/board/terrain_floor_%s_%d.png" # %s=board_type 1..%d 变体 -const TILE_FLOOR_VARIANTS := 5 # 地面瓦片变体数(词表 §一 floor_1..5) +# 瓦片纹理路径与 16 墙瓦片词表单点 = TilePalette 常量族(装载/查表同件;词表注释见该件) var _wrap: CenterContainer var _svp_container: SubViewportContainer @@ -96,26 +74,54 @@ var _desat_mat: ShaderMaterial var _desat_tween: Tween var _cell := 46.0 # 当前格边长(set_cell_area 按容器尺寸重算——battle.md 格尺寸公式) -# ── 英雄单位精灵状态(素材 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 _palette = _TILE_PALETTE.new() # 瓦片件:装载缓存+查表+明度/墨族静态算法 +var _hero_sprite = _HERO_SPRITE.new() # 英雄精灵件:_init 挂为本件子级(tween 需在树上) -# ── 瓦片状态(素材缺位 = 全 false/空——程序化平色回退)── -var _tiles := {} # 词表键 → Texture2D(镜像件已预翻转;装载见 _load_board_tiles) -var _floor_tiles: Array[Texture2D] = [] # 地面瓦片五变体(下标 0..4 = floor_1..5) -var _tiles_ok := false # false = 素材缺位/未装载——bg_sb 程序化平色原路径 -var _seal_tex: Texture2D = null # 封印印记纹理(缺盘=null=字形「封印」回退——独立可选件不连瓦片整批) -var _tiles_board_type := "dungeon" # 当前装载瓦片套的 board_type(rebuild 按板切换) -var _digit_dark_ink := true # 数字墨族按板自适应(true=深墨三档)。缺盘平色回退态 #B39A78 裸土 - # 为中明度底——深墨族 CR 4.96 达标、亮墨族仅 2.2,故缺盘默认深墨;瓦片装载后按实测分位数覆写 -var _digit_plate := false # 数字色牌垫底模式(双峰纹理板——直画两族皆不过门槛时落牌, - # 素牌+深墨 12:1 纹理免疫;渲染帧取证 underdark 系深墨直画隐形,故启用色牌) +# ── 件成员同名转发(探针直读面零改动:读 _seal_tex、读写 _hero_tex_idle;其余为随迁状态单点再导出)── +var _tiles: Dictionary[String, Texture2D]: + get: + return _palette.tiles +var _floor_tiles: Array[Texture2D]: + get: + return _palette.floor_tiles +var _tiles_ok: bool: + get: + return _palette.tiles_ok +var _seal_tex: Texture2D: + get: + return _palette.seal_tex +var _tiles_board_type: String: + get: + return _palette.tiles_board_type +var _digit_dark_ink: bool: + get: + return _palette.digit_dark_ink +var _digit_plate: bool: + get: + return _palette.digit_plate +var _hero_tex_idle: Texture2D: + get: + return _hero_sprite.tex_idle + set(v): + _hero_sprite.tex_idle = v +var _hero_tex_walk: Texture2D: + get: + return _hero_sprite.tex_walk +var _hero_last: Vector2i: + get: + return _hero_sprite.last + set(v): + _hero_sprite.last = v +var _hero_move_tw: Tween: + get: + return _hero_sprite.move_tw +var _hero_bump_tw: Tween: + get: + return _hero_sprite.bump_tw +var _hero_dig_tw: Tween: + get: + return _hero_sprite.dig_tw # ---- 视野迷雾(纯呈现层;现行事实源=HUD框架「视野迷雾」节 v0.13,渲染规格=art-bible §6.5 v3.5)---- const FOG_RADIUS := 8 # 菱形灯光区基准半径(曼哈顿距离 ≤R——与微光环「周围 8 格」同圈起步;半径基准值改动=非白名单须用户) @@ -149,12 +155,15 @@ func _init() -> void: _svp_container.add_child(_viewport) _world = _BOARD_WORLD.new() _viewport.add_child(_world) + _hero_sprite.bind(_world, _svp_container) + _hero_sprite.set_cell_size(_cell, float(SEP)) + add_child(_hero_sprite) _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) - _load_board_tiles() # 高墙/地面瓦片整批守卫装载(缺位静默回退——英雄同款纪律,禁刷屏) + _palette.load_board_tiles() # 高墙/地面瓦片整批守卫装载(缺位静默回退——英雄同款纪律,禁刷屏) func board() -> BoardData: @@ -232,6 +241,7 @@ func set_cell_area(avail: Vector2) -> void: 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)) + _hero_sprite.set_cell_size(_cell, float(SEP)) var stride := _cell + float(SEP) for c in _cells: var u: Node2D = _cells[c] @@ -258,6 +268,7 @@ func _refresh_slab() -> void: func rebuild(board: BoardData, minesweeper: MinesweeperRules) -> void: _board = board _minesweeper = minesweeper + _palette.bind_board(_board, _minesweeper) # 查表族棋盘态随板同步(换板后首拍 refresh 前置) for child in _world.get_children(): child.queue_free() _world.hero = null # 英雄精灵随清场复位——下拍 refresh 经 ensure_hero 重建(首建即时落位) @@ -274,7 +285,7 @@ func rebuild(board: BoardData, minesweeper: MinesweeperRules) -> void: # 同图型重复 rebuild 零重载,_tiles_board_type 守卫) var bt: String = _board.board_type if _board.board_type != "" else "dungeon" if bt != _tiles_board_type: - _load_board_tiles(bt) + _palette.load_board_tiles(bt) var stride := _cell + float(SEP) for y in _board.height: for x in _board.width: @@ -380,91 +391,27 @@ func _install_entrance_fx() -> void: _ripple_tws.append(tw) -# ---- 英雄单位精灵(计划书 §3.A/B;格面五 texture 槽零改动) ---- +# ---- 英雄单位精灵(件 = hero_sprite.gd;本视图保留调用面与探针直读转发) ---- -# refresh diff 驱动:位置变化 = 位移演出(140ms Tween + 走路帧);零位移但有受理移动回执 = 只播帧 -# 不位移(撞墙语义——排雷玩法「目标=当前格」幂等受理)。素材缺位 = 零精灵,字形「人」回退路径。 +# refresh diff 驱动(件内实现:位置变化 = 位移演出;零位移但受理移动回执 = 只播帧闪) 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 + _hero_sprite.update_hero(hero) -# 英雄落位(instant = 即位无演出——首建/格边长变更;否则 140ms 位移 + 走路帧起手、到位回站帧。 -# 帧权威单一化:Sprite2D 换纹理路线(计划书 §3.B)——换纹理+flip_h 同拍写定即生效, -# 无 AnimatedSprite2D 的 play()/advance(0) 错向面) +# 英雄落位(instant = 即位无演出——首建/格边长变更;否则 140ms 位移 + 走路帧起手、到位回站帧) 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) + _hero_sprite.place_hero(from, to, instant) # 受理移动指令回执(组合根 settle 受理后、refresh 前调用)——原地移动经此触发帧闪; # 目标异格时本封印标被 diff 位移路径自然吞掉(置位即清) func notify_hero_move() -> void: - _hero_bump_pending = true + _hero_sprite.notify_move() -# 挖掘角色弹跳(计划书 §3.C:scale.y 1→0.82→1,脚底锚收敛不悬空;与位移/帧闪 tween 分开管理 -# 避免属性争抢——旧弹跳先 kill) +# 挖掘角色弹跳(计划书 §3.C:scale.y 1→0.82→1,脚底锚收敛不悬空;旧弹跳先 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) + _hero_sprite.play_dig_bounce() # 格左上角(board_area 全幅坐标系)——fx 同层演出定位出口(本视图与 fx 同为 board_area 全幅子件, @@ -473,25 +420,15 @@ func cell_origin(c: Vector2i) -> Vector2: return _svp_container.position + Vector2(c) * (_cell + float(SEP)) -# 英雄脚底锚点——**世界坐标**(精灵在 SubViewport 世界内:格原点 = 格×步距,禁带壳层居中偏移—— -# cell_origin 含 _svp_container.position,世界内精灵定位误用会整体右偏 ~7 格+偏离格心)。 -# 纵向 = 格底往上 25% 线(脚踩格子 1/4 处);横向格中线。身体向上延伸, -# 头越上格沿不裁剪(造立体感);锚点与精灵缩放解耦(缩放只改身体向上长多少)。 -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 盒高、水平居中( -# 盒高随 HERO_SPRITE_SCALE——手位钉在身体上不随缩放漂移) +# 英雄持握手位(fx 同层坐标系 = 壳层坐标——件内换算含 _svp_container.position 居中偏移: +# 世界脚底锚 + 壳层偏移即得) func hero_hand_point() -> Vector2: - return _svp_container.position + _hero_feet(_hero_last) \ - + Vector2(0.0, -HERO_FRAME_PX * 0.25 * HERO_SPRITE_SCALE) + return _hero_sprite.hand_point() # 英雄精灵缩放档(fx 挥铲同档出口——两演出比例恒一致) func hero_sprite_scale() -> float: - return HERO_SPRITE_SCALE + return _hero_sprite.sprite_scale() # 真实输入路径(行为探针复用):合成点击 → 与真实 gui_input 同一翻译出口 @@ -690,7 +627,7 @@ func _badge_glyph(category: String) -> String: # 数字三档色温(按板自适应双族——v3.5 冻结④):亮墨族 1-3 冷亮/4-5 暖白/6-8 金亮 #(dungeon 瓦片实测达标维持);深墨族 1-3 青墨/4-5 赭墨/6-8 锈墨(色温语义同构——underdark/ -# deep_underdark 亮白筋纹瓦片上亮墨对亮斑 CR 1.0–1.9 融字,深墨族 5.7–11.1;选族判据见 _digit_ink_mode) +# deep_underdark 亮白筋纹瓦片上亮墨对亮斑 CR 1.0–1.9 融字,深墨族 5.7–11.1;选族判据见 TilePalette.digit_ink_mode) func _digit_ink(d: int) -> Color: if _digit_dark_ink: if d <= 3: @@ -924,236 +861,17 @@ func _reset_breath() -> void: _contact_unit.queue_redraw() -# ---- 高墙/地面瓦片接线(词表 = design/research/board-tile-vocab-plan-a-2026-09-26.md) ---- +# ---- 瓦片装载与查表(件 = tile_palette.gd;本视图仅保留格面渲染消费面) ---- -# 瓦片整批守卫装载(_init 单次调用):21 枚全成才启用——任一缺位按整批缺位处理(禁同板混渲染; -# fresh checkout 无素材目录 → 静默全回退)。缺位不 push_error——可选素材缺位是预期路径非故障 -# (英雄 load_tex 纪律同款,防冒烟刷屏)。镜像件运行时 flip_x 合成,不出文件。 -func _load_board_tiles(board_type: String = "dungeon") -> void: - if board_type == "": - board_type = "dungeon" - # 16 墙瓦片装载循环节(词表 TILE_WALL_NAMES 单点; - # 词表本身被 terrain_tile_sets_test 直读,保留) - var t := {} - for wall_name in TILE_WALL_NAMES: - t[wall_name] = UiKit.load_tex(TILE_WALL_FMT % [wall_name, board_type]) - var floors: Array[Texture2D] = [] - for i in range(1, TILE_FLOOR_VARIANTS + 1): - floors.append(UiKit.load_tex(TILE_FLOOR_FMT % [board_type, i])) - for k in t: - if t[k] == null: - _request_dungeon_fallback(board_type) - return - for tex in floors: - if tex == null: - _request_dungeon_fallback(board_type) - return - # 地板归一(圣经 v3.5 冻结①/§1.4——已开格底归一世界底层档 L≈0.10,带 0.08–0.14): - # 方向稿 _v3_darken_floors 同式移植;现行瓦片 L≈0.036 低于带下沿故实为提亮; - # 本归一为程序代役(逐批装载时对 freshly-loaded 原图测光,幂等)。归一后逐像素明度顺路采集, - # 驱动数字墨族按板自适应(_digit_ink_mode——均值分位同源同像素,禁二次解码) - var lumas: Array[Variant] = [] - for i in range(floors.size()): - floors[i] = _normalize_floor_luma(floors[i], 0.10, lumas) - var ink_mode := _digit_ink_mode(lumas) - _digit_dark_ink = bool(ink_mode.dark) - _digit_plate = bool(ink_mode.plate) - # 六镜像件(词表 §二:wall_e←wall_w / wall_ne←wall_nw / wall_se←wall_sw / - # wall_end_e←wall_end_w / wall_in_ne←wall_in_nw / wall_in_se←wall_in_sw;只水平翻转禁旋转 - # ——北受光不受损) - t["wall_e"] = _flip_tex(t["wall_w"] as Texture2D) - t["wall_ne"] = _flip_tex(t["wall_nw"] as Texture2D) - t["wall_se"] = _flip_tex(t["wall_sw"] as Texture2D) - t["wall_end_e"] = _flip_tex(t["wall_end_w"] as Texture2D) - t["wall_in_ne"] = _flip_tex(t["wall_in_nw"] as Texture2D) - t["wall_in_se"] = _flip_tex(t["wall_in_sw"] as Texture2D) - _tiles = t - _floor_tiles = floors - _tiles_board_type = board_type - _tiles_ok = true - # 封印印记纹理(独立可选件:缺盘=字形回退,不触发瓦片整批回落语义) - _seal_tex = UiKit.load_tex(TILE_SEAL_MARK) - - -# 整批回落:请求套缺位 → 回落 dungeon 套(禁同板混渲染);dungeon 本身缺位 = -# 维持程序化平色回退原语义(_tiles_ok 恒 false,缺位不刷屏纪律不变) -func _request_dungeon_fallback(requested: String) -> void: - if requested != "dungeon": - _load_board_tiles("dungeon") - - -# 水平镜像贴图运行时合成(flip_h 语义):Image 级逐位翻转后重建 ImageTexture——CellUnit 零改动。 -# VRAM 压缩源先解压(flip_x 不支持压缩格式);32×32 原尺寸逐位翻转,无 ±1px 取整问题 -static func _flip_tex(tex: Texture2D) -> Texture2D: - var img := tex.get_image() - if img.is_compressed(): - img.decompress() - img.flip_x() - return ImageTexture.create_from_image(img) - - -# 地板瓦片明度归一(方向稿 _v3_darken_floors 同式移植):8×8 双线性重采样均值测光 -# (sRGB→线性域,Rec.709 系数),增益 k=(目标/实测)^(1/2.2) 钳 [0.6,1.8],逐通道 sRGB 域线性拉伸。 -# 输入恒为 freshly-loaded 原图(load_tex 每次盘读)——幂等可重入;32×32 五变体开销可忽略 -static func _normalize_floor_luma(tex: Texture2D, target_l: float = 0.10, lumas_out: Variant = null) -> Texture2D: - var img: Image = tex.get_image() - if img.is_compressed(): - img.decompress() - img.convert(Image.FORMAT_RGBA8) - var small: Image = img.duplicate() - small.resize(8, 8, Image.INTERPOLATE_BILINEAR) - var sum := 0.0 - for y in range(8): - for x in range(8): - var p := small.get_pixel(x, y) - sum += 0.2126 * _srgb_lin(p.r) + 0.7152 * _srgb_lin(p.g) + 0.0722 * _srgb_lin(p.b) - var k := pow(target_l / maxf(sum / 64.0, 0.001), 1.0 / 2.2) - k = clampf(k, 0.6, 1.8) - for y in range(img.get_height()): - for x in range(img.get_width()): - var c := img.get_pixel(x, y) - var nr := minf(c.r * k, 1.0) - var ng := minf(c.g * k, 1.0) - var nb := minf(c.b * k, 1.0) - img.set_pixel(x, y, Color(nr, ng, nb, c.a)) - if lumas_out != null: - lumas_out.append(0.2126 * _srgb_lin(nr) + 0.7152 * _srgb_lin(ng) + 0.0722 * _srgb_lin(nb)) - return ImageTexture.create_from_image(img) - - -static func _srgb_lin(c: float) -> float: - return c / 12.92 if c <= 0.04045 else pow((c + 0.055) / 1.055, 2.4) - - -# ---- 数字墨族按板自适应(墨色按关自适应+三关对比断言)---- - -const DIGIT_INK_CR_P90 := 4.5 # 墨族对地板 p90 亮斑对比度门槛(失效模式=亮白筋纹冲字,WCAG 数字体同源) -const DIGIT_INK_CR_MEAN_BRIGHT := 2.5 # 亮族对地板均值门槛(中调防吃字) -const DIGIT_INK_CR_MEAN_DARK := 3.0 # 深族对地板均值门槛(大字 AA 级——2.4 的实测观感=隐形) - -# 归一后地板逐像素明度 → 选数字呈现模式。三态:亮族直画(暗纹理板)/深族直画(中暗板)/ -# 色牌垫底(双峰纹理板——近黑斑与近白筋并存时任何墨族直画必融一峰,渲染帧取证 -# 实证 underdark 系深墨隐形,牌上深墨 12:1 纹理免疫)。家族最弱成员(亮族=金亮/深族=锈墨) -# 对 p90 与均值双门槛;亮族过选亮族,否则深族(均值门 3.0),两族皆不过落牌。 -# 实测:dungeon p90=0.121 亮族 4.79/5.80 ✓ 直画;underdark p90=0.406 亮族 ✗、 -# 深族 5.34/**2.41**(均值 <3.0 ✗)→ 落牌;deep_underdark 同→落牌 -static func _digit_ink_mode(lumas: Array) -> Dictionary: - var sorted := lumas.duplicate() - sorted.sort() - var n := sorted.size() - if n == 0: - return {"dark": false, "plate": false, "cr_p90_bright": 0.0, "cr_mean_bright": 0.0, "cr_p90_dark": 0.0, "cr_mean_dark": 0.0} - var p90: float = sorted[int(n * 0.9)] - var mean := 0.0 - for v in sorted: - mean += v - mean /= n - var cr_p90_b := _contrast_y(UiKit.INK_L_GOLD, p90) - var cr_mean_b := _contrast_y(UiKit.INK_L_GOLD, mean) - var cr_p90_d := _contrast_y(UiKit.INK_RUST, p90) - var cr_mean_d := _contrast_y(UiKit.INK_RUST, mean) - var bright_ok := cr_p90_b >= DIGIT_INK_CR_P90 and cr_mean_b >= DIGIT_INK_CR_MEAN_BRIGHT - var dark_ok := cr_p90_d >= DIGIT_INK_CR_P90 and cr_mean_d >= DIGIT_INK_CR_MEAN_DARK - var plate := false - var dark := false - if bright_ok: - dark = false - elif dark_ok: - dark = true - else: - plate = true # 双峰纹理:直画不可救,色牌兜底(素牌+深墨 12:1) - return {"dark": dark, "plate": plate, "cr_p90_bright": cr_p90_b, "cr_mean_bright": cr_mean_b, - "cr_p90_dark": cr_p90_d, "cr_mean_dark": cr_mean_d} - - -static func _contrast_y(ink: Color, bg: float) -> float: - var y := _ink_y(ink) - return (maxf(y, bg) + 0.05) / (minf(y, bg) + 0.05) - - -static func _ink_y(c: Color) -> float: - return 0.2126 * _srgb_lin(c.r) + 0.7152 * _srgb_lin(c.g) + 0.0722 * _srgb_lin(c.b) - - -# 格坐标确定性散列(禁 RNG/禁 randomize 纪律——「初始化随机落定禁逐轮重掷」圣经原则三落点: -# 同格坐标恒同值,refresh/重绘/逐轮不变)。乘加混合 + 移位雪崩;64 位整型乘法回绕即哈希语义 -#(GDScript int 溢出静默回绕),板域 10×10/12×12 内 mod 10/mod 5 分布均匀 -static func _cell_hash(c: Vector2i) -> int: - var h := c.x * 374761393 + c.y * 668265263 - h = (h ^ (h >> 13)) * 1274126177 - return h ^ (h >> 16) - - -# 四正交邻墙判定(词表 §三.1:板外视为墙;未开格=墙、已开格=地板——世界观口径「未开=墙、 -# 已开=挖出的空间」)。唯一调用面 _paint_cell(refresh 对 _minesweeper 非空守卫在先) -func _is_wall(c: Vector2i) -> bool: - if c.x < 0 or c.y < 0 or c.x >= _board.width or c.y >= _board.height: - return true - return not _minesweeper.is_revealed(c) - - -# 墙瓦片查表(词表 §一 16 正交情形映射 + §三.2 凹角回落):nf/ef/sf/wf = 北/东/南/西邻是地板。 -# 16 情形 = 0 地板(center 族)+ 3 地板×4(端头)+ 2 地板×6(条带/凸角)+ 1 地板×4(单缘) -# + 4 地板(lone),逐分支穷尽 +# 墙瓦片查表(词表 §一 16 正交情形映射 + §三.2 凹角回落;查表用棋盘态由件持 bind_board 同步) func _wall_tile_for(c: Vector2i) -> Texture2D: - var nf := not _is_wall(c + Vector2i(0, -1)) - var ef := not _is_wall(c + Vector2i(1, 0)) - var sf := not _is_wall(c + Vector2i(0, 1)) - var wf := not _is_wall(c + Vector2i(-1, 0)) - if nf and ef and sf and wf: - return _wall_center_tile(c) # 四邻皆墙 → 对角凹角/中心三变体(下位函数) - if nf and ef and sf: - return _tiles["wall_end_w"] # 仅西墙(端头·东连墙) - if ef and sf and wf: - return _tiles["wall_end_s"] # 仅北墙(端头·南连地板、北连墙) - if nf and sf and wf: - return _tiles["wall_end_e"] # 仅东墙(= end_w 镜像) - if nf and ef and wf: - return _tiles["wall_end_n"] # 仅南墙(端头·北向死头、南连墙) - if nf and sf: - return _tiles["wall_hrun"] # 上下地板 = 横条带中段 - if ef and wf: - return _tiles["wall_vrun"] # 左右地板 = 竖条带中段 - if nf and wf: - return _tiles["wall_nw"] # 凸角(上+左地板) - if nf and ef: - return _tiles["wall_ne"] # 凸角镜像 - if sf and wf: - return _tiles["wall_sw"] # 凸角(下+左地板) - if sf and ef: - return _tiles["wall_se"] # 凸角镜像 - if nf: - return _tiles["wall_n"] # 北缘(上方地板) - if ef: - return _tiles["wall_e"] # 东缘(镜像件) - if sf: - return _tiles["wall_s"] # 南缘(下方地板) - return _tiles["wall_w"] # 西缘(仅西地板——16 情形穷尽收尾) + return _palette.wall_tile_for(c) -# 四邻皆墙格:对角凹角优先 nw > ne > sw > se(词表 §三.2——双凹角不做专件,多对角地板回落 -# 首中单凹角件,残留 1px 缝为可接受限);无对角地板 → 中心三变体按格坐标哈希 60/20/20 -func _wall_center_tile(c: Vector2i) -> Texture2D: - if not _is_wall(c + Vector2i(-1, -1)): - return _tiles["wall_in_nw"] # 西北对角是地板 - if not _is_wall(c + Vector2i(1, -1)): - return _tiles["wall_in_ne"] # 东北对角是地板(镜像件) - if not _is_wall(c + Vector2i(-1, 1)): - return _tiles["wall_in_sw"] # 西南对角是地板 - if not _is_wall(c + Vector2i(1, 1)): - return _tiles["wall_in_se"] # 东南对角是地板(镜像件) - var m := posmod(_cell_hash(c), 10) - if m < 6: - return _tiles["wall_center"] # 6/10 = 60% - if m < 8: - return _tiles["wall_center_crack"] # 2/10 = 20% - return _tiles["wall_center_speckle"] # 2/10 = 20% - - -# 地面瓦片五变体按格坐标哈希均匀分配(词表 §一 floor_1..5 / §三.3「同法哈希分配」; -# posmod 保非负下标) +# 地面瓦片五变体按格坐标哈希分配(词表 §三.3「同法哈希分配」) func _floor_tile_for(c: Vector2i) -> Texture2D: - return _floor_tiles[posmod(_cell_hash(c), _floor_tiles.size())] + return _palette.floor_tile_for(c) + # ---- 自绘光标纹理(镐/封印——引擎内置光标无此语义;程序化占位,headless 跳过) ---- diff --git a/game/presentation/views/board_world.gd b/game/presentation/views/board_world.gd index d3eaa73f..1145a970 100644 --- a/game/presentation/views/board_world.gd +++ b/game/presentation/views/board_world.gd @@ -15,7 +15,7 @@ var hero: Sprite2D = null # 英雄单位精灵(移动实体独立于 CellU #(结算期与 CellUnit 同口径)。返回值便于 BoardView 即时落位。 # 脚下椭圆影(走 Godot 组件合成、贴图零改动):径向渐变纹理 ×黑色半透明 # modulate 压扁,挂英雄子件(z_index=-1 相对回落格面之上、英雄之下——位移/翻转/弹跳随父件 -# 同步免费拿;尺寸换算归 BoardView.HERO_SHADOW_PX 常量)。纹理 filter 用 LINEAR 保渐变平滑 +# 同步免费拿;尺寸换算归 HeroSprite.SHADOW_PX 常量)。纹理 filter 用 LINEAR 保渐变平滑 #(NEAREST 会把渐变打回条带)。 func ensure_hero(tex: Texture2D, shadow_px: Vector2, scale: float) -> Sprite2D: if hero != null: diff --git a/game/presentation/views/hero_sprite.gd b/game/presentation/views/hero_sprite.gd new file mode 100644 index 00000000..c1f93cf3 --- /dev/null +++ b/game/presentation/views/hero_sprite.gd @@ -0,0 +1,152 @@ +## VERTICAL SLICE - NOT FOR PRODUCTION +## 战斗屏英雄小人精灵件:落位与位移演出、撞墙帧闪、挖掘弹跳、脚底锚与持握手位出口。 +## Node2D 件(create_tween 系 Node 方法,RefCounted 无此 API)——由棋盘视图在 _init 建为子件, +## 注入世界根与壳层容器引用;精灵本体经世界根 ensure_hero 建于 SubViewport 世界内,本件只驱动其状态。 +## 素材缺位 = 零精灵(棋盘视图字形「人」回退路径在役)。 +class_name HeroSprite +extends Node2D + +# 英雄单位演出参数(计划书 §3.B/C 时长档) +const FRAME_PX := 32.0 # 英雄帧画布边长(素材 32×32 锁定——缩放比分母) +const SPRITE_SCALE := 1.5 # 1.5× 档(实机 1× 偏小故放大 1.5 倍——本档为圣经 §8.2「禁非整数缩放」 + # 一般规则的显式豁免;头可越格上沿不裁剪) +const GROUND_FRAC := 0.25 # 脚锚纵向落点 = 格底往上 25%(口径「脚踩格子 1/4 处·从下往上数」; + # 头越上格沿造立体感。⚠ 参照系纪律:本值一律自底边起算,禁改用「自顶 75%」 + # 一类的同线异名表述) +const MOVE_S := 0.14 # 位移时长(~140ms) +const STEP_S := 0.18 # 走路帧周期 = 原地撞墙帧闪时长(~180ms) +const DIG_S := 0.15 # 挖掘弹跳总时长(~150ms,半程压半程回) +const BOUNCE := 0.82 # 弹跳压缩比(scale.y 1→0.82→1 脚底收敛) +const SHADOW_PX := Vector2(32, 12) # 脚下椭圆影屏幕尺寸(Godot 组件合成——径向渐变纹理压扁, + # 贴图零改动,走引擎侧) + +# ── 精灵状态(素材 PNG 不入 git——装载在棋盘视图 _init,缺位=字形回退)── +var tex_idle: Texture2D # 站立帧(null = 素材缺位——零精灵,字形「人」原路径) +var tex_walk: Texture2D # 跨步帧 +var last := Vector2i(-99, -99) # 上拍英雄格(refresh diff 基准;哨兵 = 未落位/随 rebuild 复位) +var face_left := true # 朝向(素材只入库朝左帧——右向 = flip_h,计划书 §2 约定) +var bump_pending := false # 受理零位移移动回执(refresh diff 只认位置变化,原地移动须外部告知) +var move_tw: Tween +var bump_tw: Tween +var dig_tw: Tween + +# ── 几何注入(棋盘视图 set_cell_area 下发;世界脚底锚公式与壳层手位换算同源)── +var cell := 0.0 # 当前格边长 +var sep := 0.0 # 格间距 +var world: Node2D = null # 世界根(ensure_hero/hero 所在) +var svp: SubViewportContainer = null # 壳层容器(手位须含其居中偏移) + + +func bind(world_ref: Node2D, svp_ref: SubViewportContainer) -> void: + world = world_ref + svp = svp_ref + + +func set_cell_size(cell_size: float, sep_px: float) -> void: + cell = cell_size + sep = sep_px + + +# refresh diff 驱动:位置变化 = 位移演出(140ms Tween + 走路帧);零位移但有受理移动回执 = 只播帧 +# 不位移(撞墙语义——排雷玩法「目标=当前格」幂等受理)。素材缺位 = 零精灵,字形「人」回退路径。 +func update_hero(hero: Vector2i) -> void: + if tex_idle == null: + return + if world.hero == null: + var sp: Sprite2D = world.ensure_hero(tex_idle, SHADOW_PX, SPRITE_SCALE) + sp.scale = Vector2.ONE * SPRITE_SCALE + sp.position = feet(hero) + last = hero # 首建/随 rebuild 重建——即时落位无演出 + return + if hero == last and not bump_pending: + return + if hero != last and hero.x != last.x: + face_left = hero.x < last.x + place_hero(last, hero, hero == last) + if hero == last: + frame_flash() + bump_pending = false + 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 := SPRITE_SCALE + var feet_px := feet(to) + if move_tw != null and move_tw.is_valid(): + move_tw.kill() + if instant: + sp.scale = Vector2(s, s) + sp.position = feet_px + sp.texture = tex_idle + return + sp.scale = Vector2(s, s) # 位移重置弹跳残形(scale.y 由 play_dig_bounce 独立回收) + sp.texture = tex_walk + sp.flip_h = not face_left # 素材基准朝左 + move_tw = create_tween() + move_tw.tween_property(sp, "position", feet_px, UiKit.motion(MOVE_S)).set_trans(Tween.TRANS_SINE) + move_tw.tween_callback(func() -> void: + if is_instance_valid(sp): + sp.texture = tex_idle) + + +# 原地移动帧闪(撞墙语义):跨步帧 ~180ms 后回站帧,零位移 +func frame_flash() -> void: + var sp: Sprite2D = world.hero + if sp == null: + return + if bump_tw != null and bump_tw.is_valid(): + bump_tw.kill() + sp.texture = tex_walk + sp.flip_h = not face_left + bump_tw = create_tween() + bump_tw.tween_interval(UiKit.motion(STEP_S)) + bump_tw.tween_callback(func() -> void: + if is_instance_valid(sp): + sp.texture = tex_idle) + + +# 受理移动指令回执(组合根 settle 受理后、refresh 前调用)——原地移动经此触发帧闪; +# 目标异格时本封印标被 diff 位移路径自然吞掉(置位即清) +func notify_move() -> void: + bump_pending = true + + +# 挖掘角色弹跳(计划书 §3.C:scale.y 1→0.82→1,脚底锚收敛不悬空;与位移/帧闪 tween 分开管理 +# 避免属性争抢——旧弹跳先 kill) +func play_dig_bounce() -> void: + var sp: Sprite2D = world.hero + if sp == null or tex_idle == null: + return + if dig_tw != null and dig_tw.is_valid(): + dig_tw.kill() + var s := SPRITE_SCALE + sp.scale = Vector2(s, s) + dig_tw = create_tween() + dig_tw.tween_property(sp, "scale:y", s * BOUNCE, UiKit.motion(DIG_S * 0.5)).set_trans(Tween.TRANS_SINE) + dig_tw.tween_property(sp, "scale:y", s, UiKit.motion(DIG_S * 0.5)).set_trans(Tween.TRANS_SINE) + + +# 英雄脚底锚点——**世界坐标**(精灵在 SubViewport 世界内:格原点 = 格×步距,禁带壳层居中偏移—— +# cell_origin 含 svp.position,世界内精灵定位误用会整体右偏 ~7 格+偏离格心)。 +# 纵向 = 格底往上 25% 线(脚踩格子 1/4 处);横向格中线。身体向上延伸, +# 头越上格沿不裁剪(造立体感);锚点与精灵缩放解耦(缩放只改身体向上长多少)。 +func feet(c: Vector2i) -> Vector2: + return Vector2(c) * (cell + sep) + Vector2(cell * 0.5, cell * (1.0 - GROUND_FRAC)) + + +# 英雄持握手位(fx 同层坐标系 = 壳层坐标——fx 在 SubViewport 之外,须含 svp.position +# 居中偏移:世界脚底锚 + 壳层偏移即得)。手 = 盒底往上 1/4 盒高、水平居中( +# 盒高随 SPRITE_SCALE——手位钉在身体上不随缩放漂移) +func hand_point() -> Vector2: + return svp.position + feet(last) + Vector2(0.0, -FRAME_PX * 0.25 * SPRITE_SCALE) + + +# 英雄精灵缩放档(fx 挥铲同档出口——两演出比例恒一致) +func sprite_scale() -> float: + return SPRITE_SCALE diff --git a/game/presentation/views/hero_sprite.gd.uid b/game/presentation/views/hero_sprite.gd.uid new file mode 100644 index 00000000..4926a3ea --- /dev/null +++ b/game/presentation/views/hero_sprite.gd.uid @@ -0,0 +1 @@ +uid://b2tr6ar845ogx diff --git a/game/presentation/views/tile_palette.gd b/game/presentation/views/tile_palette.gd new file mode 100644 index 00000000..bedb7c69 --- /dev/null +++ b/game/presentation/views/tile_palette.gd @@ -0,0 +1,261 @@ +## VERTICAL SLICE - NOT FOR PRODUCTION +## 棋盘瓦片件:高墙/地面瓦片整批装载与运行时镜像合成、地板明度归一、数字墨族按板自适应(静态算法族)、 +## 格坐标确定性散列与十六正交情形墙瓦片查表。装载缓存七成员持于件内(同板型零重载、换板重载时序同源), +## 查表族读件持棋盘引用(棋盘视图 rebuild 经 bind_board 同步)。零绘制面、零玩法数据写入。 +class_name TilePalette +extends RefCounted + +# ── 高墙/地面瓦片纹理路径单点 ── +# 素材 PNG 不入 git——一律 UiKit.load_tex(exists 守卫)装载,禁 preload; +# 缺位返回 null,整批任一缺位 = 弃用瓦片走程序化平色回退(棋盘视图 BRICK/FLOOR,fresh checkout 冒烟不炸)。 +# 词表 = design/research/board-tile-vocab-plan-a-2026-09-26.md(16 唯一墙瓦片 + 5 地面变体; +# 东/东北/东南/end_e/in_ne/in_se 六镜像件运行时 flip_x 合成,不出文件——词表 §二)。 +# 路径按 board_type 后缀参数化——terrain_<件名>_.png; +# 词表 §一 16 唯一墙瓦片名单单点在本常量数组。underdark/deep_underdark 两套 = +# tools/recolor_terrain_tiles.py 程序换色产;任一套缺位 → 整批回落 +# dungeon 套(禁同板混渲染);dungeon 套亦缺位 → 程序化平色回退(fresh checkout 冒烟不炸)。 +const TILE_WALL_NAMES := ["wall_center", "wall_center_crack", "wall_center_speckle", "wall_n", "wall_s", "wall_w", "wall_nw", "wall_sw", "wall_hrun", "wall_vrun", "wall_end_n", "wall_end_s", "wall_end_w", "wall_lone", "wall_in_nw", "wall_in_sw"] +const TILE_WALL_FMT := "res://assets/art/board/terrain_%s_%s.png" # %s=件名 %s=board_type +const TILE_SEAL_MARK := "res://assets/art/board/seal_mark.png" # 封印印记纹理(独立可选件) +const TILE_FLOOR_FMT := "res://assets/art/board/terrain_floor_%s_%d.png" # %s=board_type 1..%d 变体 +const TILE_FLOOR_VARIANTS := 5 # 地面瓦片变体数(词表 §一 floor_1..5) + +# ---- 数字墨族按板自适应(墨色按关自适应+三关对比断言)---- + +const DIGIT_INK_CR_P90 := 4.5 # 墨族对地板 p90 亮斑对比度门槛(失效模式=亮白筋纹冲字,WCAG 数字体同源) +const DIGIT_INK_CR_MEAN_BRIGHT := 2.5 # 亮族对地板均值门槛(中调防吃字) +const DIGIT_INK_CR_MEAN_DARK := 3.0 # 深族对地板均值门槛(大字 AA 级——2.4 的实测观感=隐形) + +# 墙瓦片 16 正交情形映射(下标 = nf<<3|ef<<2|sf<<1|wf,nf/ef/sf/wf = 北/东/南/西邻是地板)。 +# 词表 §一 16 情形 = 0 地板(center 族,键 15 另走对角凹角/中心三变体)+ 3 地板×4(端头)+ +# 2 地板×6(条带/凸角)+ 1 地板×4(单缘)+ 4 地板(对角凹角/中心三变体);lone 件与原实现同址 +# 不入查表(0 地板收尾落 wall_w) +const WALL_MASK_TILE := ["wall_w", "wall_w", "wall_s", "wall_sw", "wall_e", "wall_vrun", "wall_se", + "wall_end_s", "wall_n", "wall_nw", "wall_hrun", "wall_end_e", "wall_ne", "wall_end_n", + "wall_end_w", "wall_center"] +# 四邻皆墙格:对角凹角优先 nw > ne > sw > se(词表 §三.2——双凹角不做专件,多对角地板回落 +# 首中单凹角件,残留 1px 缝为可接受限) +const WALL_DIAG_DIRS := [Vector2i(-1, -1), Vector2i(1, -1), Vector2i(-1, 1), Vector2i(1, 1)] +const WALL_DIAG_TILES := ["wall_in_nw", "wall_in_ne", "wall_in_sw", "wall_in_se"] +# 无对角地板 → 中心三变体按格坐标哈希 60/20/20(posmod 保非负下标,10 槽展开免分支) +const WALL_CENTER_PICK := ["wall_center", "wall_center", "wall_center", "wall_center", "wall_center", + "wall_center", "wall_center_crack", "wall_center_crack", "wall_center_speckle", "wall_center_speckle"] + +# ── 装载缓存(原棋盘视图同名成员随迁;缺位 = 全 false/空——程序化平色回退)── +var tiles: Dictionary[String, Texture2D] = {} # 词表键 → Texture2D(镜像件已预翻转) +var floor_tiles: Array[Texture2D] = [] # 地面瓦片五变体(下标 0..4 = floor_1..5) +var tiles_ok := false # false = 素材缺位/未装载——调用方走程序化平色原路径 +var seal_tex: Texture2D = null # 封印印记纹理(缺盘=null=字形回退——独立可选件不连瓦片整批) +var tiles_board_type := "dungeon" # 当前装载瓦片套的 board_type(rebuild 按板切换) +var digit_dark_ink := true # 数字墨族按板自适应(true=深墨三档)。缺盘平色回退态 #B39A78 裸土 + # 为中明度底——深墨族 CR 4.96 达标、亮墨族仅 2.2,故缺盘默认深墨;瓦片装载后按实测分位数覆写 +var digit_plate := false # 数字色牌垫底模式(双峰纹理板——直画两族皆不过门槛时落牌, + # 素牌+深墨 12:1 纹理免疫;渲染帧取证 underdark 系深墨直画隐形,故启用色牌) + +# ── 查表用棋盘态(rebuild 换板时经 bind_board 同步)── +var board: BoardData = null +var minesweeper: MinesweeperRules = null + + +func bind_board(board_ref: BoardData, minesweeper_ref: MinesweeperRules) -> void: + board = board_ref + minesweeper = minesweeper_ref + + +# 瓦片整批守卫装载(首次建板与换板型各一次):21 枚全成才启用——任一缺位按整批缺位处理 +#(禁同板混渲染;fresh checkout 无素材目录 → 静默全回退)。缺位不 push_error——可选素材缺位是 +# 预期路径非故障(英雄 load_tex 纪律同款,防冒烟刷屏)。镜像件运行时 flip_x 合成,不出文件。 +func load_board_tiles(board_type: String = "dungeon") -> void: + if board_type == "": + board_type = "dungeon" + # 16 墙瓦片装载循环节(词表 TILE_WALL_NAMES 单点; + # 词表本身被 terrain_tile_sets_test 直读,保留) + var t: Dictionary[String, Texture2D] = {} + for wall_name in TILE_WALL_NAMES: + t[wall_name] = UiKit.load_tex(TILE_WALL_FMT % [wall_name, board_type]) + var floors: Array[Texture2D] = [] + for i in range(1, TILE_FLOOR_VARIANTS + 1): + floors.append(UiKit.load_tex(TILE_FLOOR_FMT % [board_type, i])) + for k in t: + if t[k] == null: + _request_dungeon_fallback(board_type) + return + for tex in floors: + if tex == null: + _request_dungeon_fallback(board_type) + return + # 地板归一(圣经 v3.5 冻结①/§1.4——已开格底归一世界底层档 L≈0.10,带 0.08–0.14): + # 方向稿 _v3_darken_floors 同式移植;现行瓦片 L≈0.036 低于带下沿故实为提亮; + # 本归一为程序代役(逐批装载时对 freshly-loaded 原图测光,幂等)。归一后逐像素明度顺路采集, + # 驱动数字墨族按板自适应(digit_ink_mode——均值分位同源同像素,禁二次解码) + var lumas: Array[Variant] = [] + for i in range(floors.size()): + floors[i] = normalize_floor_luma(floors[i], 0.10, lumas) + var ink_mode := digit_ink_mode(lumas) + digit_dark_ink = bool(ink_mode.dark) + digit_plate = bool(ink_mode.plate) + # 六镜像件(词表 §二:wall_e←wall_w / wall_ne←wall_nw / wall_se←wall_sw / + # wall_end_e←wall_end_w / wall_in_ne←wall_in_nw / wall_in_se←wall_in_sw;只水平翻转禁旋转 + # ——北受光不受损) + t["wall_e"] = flip_tex(t["wall_w"]) + t["wall_ne"] = flip_tex(t["wall_nw"]) + t["wall_se"] = flip_tex(t["wall_sw"]) + t["wall_end_e"] = flip_tex(t["wall_end_w"]) + t["wall_in_ne"] = flip_tex(t["wall_in_nw"]) + t["wall_in_se"] = flip_tex(t["wall_in_sw"]) + tiles = t + floor_tiles = floors + tiles_board_type = board_type + tiles_ok = true + # 封印印记纹理(独立可选件:缺盘=字形回退,不触发瓦片整批回落语义) + seal_tex = UiKit.load_tex(TILE_SEAL_MARK) + + +# 整批回落:请求套缺位 → 回落 dungeon 套(禁同板混渲染);dungeon 本身缺位 = +# 维持程序化平色回退原语义(tiles_ok 恒 false,缺位不刷屏纪律不变) +func _request_dungeon_fallback(requested: String) -> void: + if requested != "dungeon": + load_board_tiles("dungeon") + + +# 水平镜像贴图运行时合成(flip_h 语义):Image 级逐位翻转后重建 ImageTexture——CellUnit 零改动。 +# VRAM 压缩源先解压(flip_x 不支持压缩格式);32×32 原尺寸逐位翻转,无 ±1px 取整问题 +static func flip_tex(tex: Texture2D) -> Texture2D: + var img := tex.get_image() + if img.is_compressed(): + img.decompress() + img.flip_x() + return ImageTexture.create_from_image(img) + + +# 地板瓦片明度归一(方向稿 _v3_darken_floors 同式移植):8×8 双线性重采样均值测光 +# (sRGB→线性域,Rec.709 系数),增益 k=(目标/实测)^(1/2.2) 钳 [0.6,1.8],逐通道 sRGB 域线性拉伸。 +# 输入恒为 freshly-loaded 原图(load_tex 每次盘读)——幂等可重入;32×32 五变体开销可忽略 +static func normalize_floor_luma(tex: Texture2D, target_l: float = 0.10, lumas_out: Variant = null) -> Texture2D: + var img: Image = tex.get_image() + if img.is_compressed(): + img.decompress() + img.convert(Image.FORMAT_RGBA8) + var small: Image = img.duplicate() + small.resize(8, 8, Image.INTERPOLATE_BILINEAR) + var sum := 0.0 + for y in range(8): + for x in range(8): + var p := small.get_pixel(x, y) + sum += 0.2126 * srgb_lin(p.r) + 0.7152 * srgb_lin(p.g) + 0.0722 * srgb_lin(p.b) + var k := pow(target_l / maxf(sum / 64.0, 0.001), 1.0 / 2.2) + k = clampf(k, 0.6, 1.8) + for y in range(img.get_height()): + for x in range(img.get_width()): + var c := img.get_pixel(x, y) + var nr := minf(c.r * k, 1.0) + var ng := minf(c.g * k, 1.0) + var nb := minf(c.b * k, 1.0) + img.set_pixel(x, y, Color(nr, ng, nb, c.a)) + if lumas_out != null: + lumas_out.append(0.2126 * srgb_lin(nr) + 0.7152 * srgb_lin(ng) + 0.0722 * srgb_lin(nb)) + return ImageTexture.create_from_image(img) + + +static func srgb_lin(c: float) -> float: + return c / 12.92 if c <= 0.04045 else pow((c + 0.055) / 1.055, 2.4) + + +# 归一后地板逐像素明度 → 选数字呈现模式。三态:亮族直画(暗纹理板)/深族直画(中暗板)/ +# 色牌垫底(双峰纹理板——近黑斑与近白筋并存时任何墨族直画必融一峰,渲染帧取证 +# 实证 underdark 系深墨隐形,牌上深墨 12:1 纹理免疫)。家族最弱成员(亮族=金亮/深族=锈墨) +# 对 p90 与均值双门槛;亮族过选亮族,否则深族(均值门 3.0),两族皆不过落牌。 +# 实测:dungeon p90=0.121 亮族 4.79/5.80 ✓ 直画;underdark p90=0.406 亮族 ✗、 +# 深族 5.34/**2.41**(均值 <3.0 ✗)→ 落牌;deep_underdark 同→落牌 +static func digit_ink_mode(lumas: Array) -> Dictionary: + var sorted := lumas.duplicate() + sorted.sort() + var n := sorted.size() + if n == 0: + return {"dark": false, "plate": false, "cr_p90_bright": 0.0, "cr_mean_bright": 0.0, "cr_p90_dark": 0.0, "cr_mean_dark": 0.0} + var p90: float = sorted[int(n * 0.9)] + var crs := _family_contrasts(p90, _mean_of(sorted)) + var bright_ok: bool = crs.x >= DIGIT_INK_CR_P90 and crs.z >= DIGIT_INK_CR_MEAN_BRIGHT + var dark_ok: bool = crs.y >= DIGIT_INK_CR_P90 and crs.w >= DIGIT_INK_CR_MEAN_DARK + return {"dark": not bright_ok and dark_ok, "plate": not bright_ok and not dark_ok, + "cr_p90_bright": crs.x, "cr_mean_bright": crs.z, "cr_p90_dark": crs.y, "cr_mean_dark": crs.w} + + +# 均值(明度集合逐元素算术平均) +static func _mean_of(values: Array) -> float: + var sum := 0.0 + for v in values: + sum += v + return sum / float(values.size()) + + +# 两族对比度四元组:x=亮族 p90 / y=深族 p90 / z=亮族均值 / w=深族均值 +static func _family_contrasts(p90: float, mean: float) -> Vector4: + return Vector4(contrast_y(UiKit.INK_L_GOLD, p90), contrast_y(UiKit.INK_RUST, p90), + contrast_y(UiKit.INK_L_GOLD, mean), contrast_y(UiKit.INK_RUST, mean)) + + +static func contrast_y(ink: Color, bg: float) -> float: + var y := ink_y(ink) + return (maxf(y, bg) + 0.05) / (minf(y, bg) + 0.05) + + +static func ink_y(c: Color) -> float: + return 0.2126 * srgb_lin(c.r) + 0.7152 * srgb_lin(c.g) + 0.0722 * srgb_lin(c.b) + + +# 格坐标确定性散列(禁 RNG/禁 randomize 纪律——「初始化随机落定禁逐轮重掷」圣经原则三落点: +# 同格坐标恒同值,refresh/重绘/逐轮不变)。乘加混合 + 移位雪崩;64 位整型乘法回绕即哈希语义 +#(GDScript int 溢出静默回绕),板域 10×10/12×12 内 mod 10/mod 5 分布均匀 +static func cell_hash(c: Vector2i) -> int: + var h := c.x * 374761393 + c.y * 668265263 + h = (h ^ (h >> 13)) * 1274126177 + return h ^ (h >> 16) + + +# 四正交邻墙判定(词表 §三.1:板外视为墙;未开格=墙、已开格=地板——世界观口径「未开=墙、 +# 已开=挖出的空间」)。唯一调用面 wall_tile_for/wall_center_tile(棋盘视图 refresh 对 +# 排雷玩法非空守卫在先) +func is_wall(c: Vector2i) -> bool: + if c.x < 0 or c.y < 0 or c.x >= board.width or c.y >= board.height: + return true + return not minesweeper.is_revealed(c) + + +# 四邻地板掩码(位序 = nf<<3|ef<<2|sf<<1|wf,见 WALL_MASK_TILE) +func _neighbor_mask(c: Vector2i) -> int: + var m := 0 + if not is_wall(c + Vector2i(0, -1)): + m |= 8 + if not is_wall(c + Vector2i(1, 0)): + m |= 4 + if not is_wall(c + Vector2i(0, 1)): + m |= 2 + if not is_wall(c + Vector2i(-1, 0)): + m |= 1 + return m + + +# 墙瓦片查表(词表 §一 16 正交情形映射 + §三.2 凹角回落):nf/ef/sf/wf = 北/东/南/西邻是地板。 +# 16 情形 = 0 地板(center 族)+ 3 地板×4(端头)+ 2 地板×6(条带/凸角)+ 1 地板×4(单缘) +# + 4 地板(对角凹角/中心三变体),逐键穷尽 +func wall_tile_for(c: Vector2i) -> Texture2D: + var mask := _neighbor_mask(c) + if mask == 15: + return wall_center_tile(c) # 四邻皆地板 → 对角凹角/中心三变体(下位函数) + return tiles[WALL_MASK_TILE[mask]] + + +# 四邻皆地板格:对角凹角优先 nw > ne > sw > se;无对角地板 → 中心三变体按格坐标哈希 60/20/20 +func wall_center_tile(c: Vector2i) -> Texture2D: + for i in WALL_DIAG_DIRS.size(): + var d: Vector2i = WALL_DIAG_DIRS[i] + if not is_wall(c + d): + return tiles[WALL_DIAG_TILES[i]] + return tiles[WALL_CENTER_PICK[posmod(cell_hash(c), 10)]] + + +# 地面瓦片五变体按格坐标哈希均匀分配(词表 §一 floor_1..5 / §三.3「同法哈希分配」; +# posmod 保非负下标) +func floor_tile_for(c: Vector2i) -> Texture2D: + return floor_tiles[posmod(cell_hash(c), floor_tiles.size())] diff --git a/game/presentation/views/tile_palette.gd.uid b/game/presentation/views/tile_palette.gd.uid new file mode 100644 index 00000000..fcf6e9a7 --- /dev/null +++ b/game/presentation/views/tile_palette.gd.uid @@ -0,0 +1 @@ +uid://cp4wdqe0v1l32 diff --git a/game/presentation/views/ui_kit.gd b/game/presentation/views/ui_kit.gd index 607f3b53..7431323b 100644 --- a/game/presentation/views/ui_kit.gd +++ b/game/presentation/views/ui_kit.gd @@ -10,7 +10,7 @@ const INK_OCHRE := Color8(60, 37, 12) # 赭墨 4-5 #3C250C(数字墨 const INK_RUST := Color8(74, 30, 20) # 锈墨 6-8 #4A1E14(数字墨三档·暖=密集;亮瓦自适应深墨族档兼色牌后备路径) # ── 亮墨三档(圣经 v3.5 冻结④——数字直画已开格底:色温承袭密度语义,冷亮/暖白/金亮; # 实测对比 6.0–6.3:1(A3a);金亮低饱和避奖励金语义;色牌后备组用深墨族上牌〔12:1〕。 -# 亮墨族限暗纹理板(dungeon),亮筋纹板按 _digit_ink_mode 自适应切深墨三档)── +# 亮墨族限暗纹理板(dungeon),亮筋纹板按 TilePalette.digit_ink_mode 自适应切深墨三档)── const INK_L_COLD := Color8(216, 236, 240) # 冷亮 1-3 #D8ECF0 const INK_L_WARM := Color8(240, 226, 200) # 暖白 4-5 #F0E2C8(关卡屏 WARM 同族) const INK_L_GOLD := Color8(248, 227, 168) # 金亮 6-8 #F8E3A8 diff --git a/game/probe/ui_contrast_probe.gd b/game/probe/ui_contrast_probe.gd index bfe6403c..652fb04e 100644 --- a/game/probe/ui_contrast_probe.gd +++ b/game/probe/ui_contrast_probe.gd @@ -136,18 +136,18 @@ func _fog_derived_check() -> Array: # 迷雾测点派生助手(迭代36 wc5 契约 3——全仓唯一「按板型算归一后均值」判据面):纹理路径经 -# BoardView.TILE_FLOOR_FMT 常量(禁复制字面量)、归一经 BoardView._normalize_floor_luma +# TilePalette.TILE_FLOOR_FMT 常量(禁复制字面量)、归一经 TilePalette.normalize_floor_luma # 生产静态函数直呼(真渲染同源——禁探针内复制 k 公式);逐像素归一后均值再五变体平均。 static func _norm_floor_avg(board_type: String) -> Color: var sum_r := 0.0 var sum_g := 0.0 var sum_b := 0.0 var px := 0 - for i in range(1, BoardView.TILE_FLOOR_VARIANTS + 1): - var tex := UiKit.load_tex(BoardView.TILE_FLOOR_FMT % [board_type, i]) + for i in range(1, TilePalette.TILE_FLOOR_VARIANTS + 1): + var tex := UiKit.load_tex(TilePalette.TILE_FLOOR_FMT % [board_type, i]) if tex == null: continue - var img: Image = BoardView._normalize_floor_luma(tex, 0.10, null).get_image() + var img: Image = TilePalette.normalize_floor_luma(tex, 0.10, null).get_image() if img.is_compressed(): img.decompress() img.convert(Image.FORMAT_RGBA8) diff --git a/game/tests/unit/presentation/terrain_tile_sets_test.gd b/game/tests/unit/presentation/terrain_tile_sets_test.gd index ddaa0454..94599bc5 100644 --- a/game/tests/unit/presentation/terrain_tile_sets_test.gd +++ b/game/tests/unit/presentation/terrain_tile_sets_test.gd @@ -8,7 +8,7 @@ extends SceneTree const ConfigDBScript := preload("res://core/config_db.gd") -const BoardViewScript := preload("res://presentation/views/board_view.gd") +const TilePaletteScript := preload("res://presentation/views/tile_palette.gd") const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点 @@ -41,8 +41,8 @@ func _init() -> void: # 套完整性:词表每个 board_type 的 16 墙 + 5 地面变体全部在盘(缺位 → BoardView 整批回落 dungeon 的 # 缺位回退断言由 ui_input_probe 覆盖;本断言管「入包完整」面) func _test_set_completeness(board_dir: String, types: Array) -> void: - var wall_names: Array = BoardViewScript.TILE_WALL_NAMES - var variants: int = int(BoardViewScript.TILE_FLOOR_VARIANTS) + var wall_names: Array = TilePaletteScript.TILE_WALL_NAMES + var variants: int = int(TilePaletteScript.TILE_FLOOR_VARIANTS) for bt in types: var missing: Array = [] for w in wall_names: @@ -119,10 +119,10 @@ func _load_stats(board_dir: String, fname: String, top_half: bool) -> Dictionary # 每个图型走 BoardView 同一归一+测光+选模式管线(禁测试侧另写算法),断言: # ①dungeon=亮墨族直画(对 p90 ≥4.5 且对均值 ≥2.5);②underdark/deep_underdark=色牌垫底 # (双峰纹理直画不可救——渲染帧取证深墨隐形实证,牌上深墨 12:1);③直画模式的双门槛值照抄 -# BoardView 判据常量(防判据漂移) +# TilePalette 判据常量(防判据漂移) func _test_digit_ink_modes(board_dir: String, types: Array) -> void: var expect_plate := {"dungeon": false, "underdark": true, "deep_underdark": true} - var variants: int = int(BoardViewScript.TILE_FLOOR_VARIANTS) + var variants: int = int(TilePaletteScript.TILE_FLOOR_VARIANTS) for bt in types: var lumas: Array = [] var readable := true @@ -132,11 +132,11 @@ func _test_digit_ink_modes(board_dir: String, types: Array) -> void: readable = false break var tex: Texture2D = ImageTexture.create_from_image(Image.load_from_file(p)) - BoardViewScript._normalize_floor_luma(tex, 0.10, lumas) + TilePaletteScript.normalize_floor_luma(tex, 0.10, lumas) if not readable: Kit.check("图型 %s 数字墨族断言样本可读" % String(bt), false) continue - var mode: Dictionary = BoardViewScript._digit_ink_mode(lumas) + var mode: Dictionary = TilePaletteScript.digit_ink_mode(lumas) var plate: bool = bool(mode["plate"]) var dark: bool = bool(mode["dark"]) var mode_name := "色牌垫底" if plate else ("深墨族直画" if dark else "亮墨族直画") @@ -144,14 +144,14 @@ func _test_digit_ink_modes(board_dir: String, types: Array) -> void: Kit.check("图型 %s 双峰纹理落牌(亮族 p90 %.2f/均值 %.2f,深族 p90 %.2f/均值 %.2f 均未过门)" % [ String(bt), float(mode["cr_p90_bright"]), float(mode["cr_mean_bright"]), float(mode["cr_p90_dark"]), float(mode["cr_mean_dark"])], - float(mode["cr_mean_dark"]) < float(BoardViewScript.DIGIT_INK_CR_MEAN_DARK)) + float(mode["cr_mean_dark"]) < float(TilePaletteScript.DIGIT_INK_CR_MEAN_DARK)) else: var cr_p90 := float(mode["cr_p90_bright"]) if not dark else float(mode["cr_p90_dark"]) var cr_mean := float(mode["cr_mean_bright"]) if not dark else float(mode["cr_mean_dark"]) Kit.check("图型 %s 所选%s 对 p90 ≥%.1f(实测 %.2f)" % [String(bt), mode_name, - float(BoardViewScript.DIGIT_INK_CR_P90), cr_p90], - cr_p90 >= float(BoardViewScript.DIGIT_INK_CR_P90)) - var mean_gate := float(BoardViewScript.DIGIT_INK_CR_MEAN_BRIGHT) if not dark else float(BoardViewScript.DIGIT_INK_CR_MEAN_DARK) + float(TilePaletteScript.DIGIT_INK_CR_P90), cr_p90], + cr_p90 >= float(TilePaletteScript.DIGIT_INK_CR_P90)) + var mean_gate := float(TilePaletteScript.DIGIT_INK_CR_MEAN_BRIGHT) if not dark else float(TilePaletteScript.DIGIT_INK_CR_MEAN_DARK) Kit.check("图型 %s 所选%s 对均值 ≥%.1f(实测 %.2f)" % [String(bt), mode_name, mean_gate, cr_mean], cr_mean >= mean_gate) if expect_plate.has(bt):