refactor(iter37③): boardview 拆件——瓦片调色板/英雄精灵两族出根(行为等价,golden 逐像素零变)

r2-1 瓦片族(排版+素材装载+墨族判据+查表)→ views/tile_palette.gd;
r2-2 英雄族(贴图/位移/影/朝向/缩放几何)→ views/hero_sprite.gd(Node2D);
r2-3 board_view 改引+直引面换指(terrain_tile_sets_test/ui_contrast_probe),
探针与 main 的组合根直读面经转发属性/常量别名保持零改动。

S13 计数守恒:基线含类变量数/公有方法数两条计数型键,故 board_view 用 13 个转发属性
+2 个组件引用压回原计数(45 变量/27 公有方法);baselined 的 wall_tile_for/
wall_center_tile/digit_ink_mode 以查表/辅助函数重构压回阈值内(非改名保违例),
旧文件级键随函数迁出自然失效——基线文件零改动(用户令:只改代码不改基线)。

验收:terrain_tile_sets_test 19 检查绿(判据读数与迁移前同值);gdstyle 基线外新违规 0;
S9/S10 绿;probe_gate --click 全绿(含 ui_input_probe 十八断言/click 走查 0 问题);
render_gate 棋盘三图+菜单 golden changed=0.00%;撤销证明:测试改引回退旧址符号
→ SCRIPT ERROR(Parse Error: Static function "_normalize_floor_luma()" not found)+
error_scan exit 1,还原后 19 检查复绿、error_scan exit 0。
This commit is contained in:
DKChan committed 2026-10-25 06:43:03 +08:00
1 parent c99c23fba3
commit 5c9c5f8083
9 files changed
+511 -378

No files matched your search

+79 -361
View File
@@ -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_<件名>_<board_type>.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 跳过) ----
+1 -1
View File
@@ -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:
+152
View File
@@ -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
@@ -0,0 +1 @@
uid://b2tr6ar845ogx
+261
View File
@@ -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_<件名>_<board_type>.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())]
@@ -0,0 +1 @@
uid://cp4wdqe0v1l32
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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)
@@ -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):