feat(art): T6 underdark/deep_underdark 瓦片换色——程序换色产 L2/L3 两套入包+机检(台账164 换色遗留清偿)
- tools/recolor_terrain_tiles.py: 槽位家族保持重映射(源唯一色→最近 dungeon 锚定家族→家族内亮度秩序线性映射目标色表);目标锚=归档生成器 PALETTES(圣经 §4.3 三关方向,三关换色证明条在案);确定性零随机;源=盘上 v9.3 定稿 21 枚(活件未动) - 产物: game/assets/art/board/terrain_*_underdark|deep_underdark.png 各 21 枚(素材不入 git 与 dungeon 套一致,.import 编辑器刷) - BoardView: 瓦片路径按 board_type 后缀参数化(词表名单单点 TILE_WALL_NAMES);rebuild 按板图型切换(_tiles_board_type 守卫零重载);套缺位→整批回落 dungeon(禁同板混渲染),dungeon 亦缺位→程序化平色原回退 - 机检: terrain_tile_sets_test 入 manifest(第 9 套件)——词表 3 型×套完整性(16 墙+5 地面)+墙顶色相域抽检(dungeon 0.089/underdark 0.625/deep 0.639 各落圣经方向带)+图底分离×3+deep 更暗方向;素材工作区缺位=SKIP 不红(不入 git 纪律) - 轻闸: 冒烟 557/0+ui_input_probe(缺位回退断言保持)+ui_click_probe+9 套件全绿
This commit is contained in:
1 parent
16f9b5d276
commit
f2642a87a6
5 files changed
+329
-35
No files matched your search
@@ -79,23 +79,13 @@ const HERO_SHADOW_PX := Vector2(32, 12) # 脚下椭圆影屏幕尺寸(Godot
|
|||||||
# 路径常量落本文件单点(ui_kit.gd 由并行批占用,任务书 §2.5 禁入)。词表 =
|
# 路径常量落本文件单点(ui_kit.gd 由并行批占用,任务书 §2.5 禁入)。词表 =
|
||||||
# design/research/board-tile-vocab-plan-a-2026-09-26.md(16 唯一墙瓦片 + 5 地面变体;
|
# design/research/board-tile-vocab-plan-a-2026-09-26.md(16 唯一墙瓦片 + 5 地面变体;
|
||||||
# 东/东北/东南/end_e/in_ne/in_se 六镜像件运行时 flip_x 合成,不出文件——词表 §二)。
|
# 东/东北/东南/end_e/in_ne/in_se 六镜像件运行时 flip_x 合成,不出文件——词表 §二)。
|
||||||
const TEX_WALL_CENTER := "res://assets/art/board/terrain_wall_center_dungeon.png"
|
# 换色批(T6 2026-09-28):路径按 board_type 后缀参数化——terrain_<件名>_<board_type>.png;
|
||||||
const TEX_WALL_CENTER_CRACK := "res://assets/art/board/terrain_wall_center_crack_dungeon.png"
|
# 词表 §一 16 唯一墙瓦片名单单点在本常量数组。underdark/deep_underdark 两套 =
|
||||||
const TEX_WALL_CENTER_SPECKLE := "res://assets/art/board/terrain_wall_center_speckle_dungeon.png"
|
# tools/recolor_terrain_tiles.py 程序换色产(台账 164 换色遗留清偿);任一套缺位 → 整批回落
|
||||||
const TEX_WALL_N := "res://assets/art/board/terrain_wall_n_dungeon.png"
|
# dungeon 套(禁同板混渲染);dungeon 套亦缺位 → 程序化平色回退(fresh checkout 冒烟不炸)。
|
||||||
const TEX_WALL_S := "res://assets/art/board/terrain_wall_s_dungeon.png"
|
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 TEX_WALL_W := "res://assets/art/board/terrain_wall_w_dungeon.png" # wall_e = 运行时镜像
|
const TILE_WALL_FMT := "res://assets/art/board/terrain_%s_%s.png" # %s=件名 %s=board_type
|
||||||
const TEX_WALL_NW := "res://assets/art/board/terrain_wall_nw_dungeon.png" # wall_ne = 运行时镜像
|
const TILE_FLOOR_FMT := "res://assets/art/board/terrain_floor_%s_%d.png" # %s=board_type 1..%d 变体
|
||||||
const TEX_WALL_SW := "res://assets/art/board/terrain_wall_sw_dungeon.png" # wall_se = 运行时镜像
|
|
||||||
const TEX_WALL_HRUN := "res://assets/art/board/terrain_wall_hrun_dungeon.png"
|
|
||||||
const TEX_WALL_VRUN := "res://assets/art/board/terrain_wall_vrun_dungeon.png"
|
|
||||||
const TEX_WALL_END_N := "res://assets/art/board/terrain_wall_end_n_dungeon.png"
|
|
||||||
const TEX_WALL_END_S := "res://assets/art/board/terrain_wall_end_s_dungeon.png"
|
|
||||||
const TEX_WALL_END_W := "res://assets/art/board/terrain_wall_end_w_dungeon.png" # wall_end_e = 运行时镜像
|
|
||||||
const TEX_WALL_LONE := "res://assets/art/board/terrain_wall_lone_dungeon.png"
|
|
||||||
const TEX_WALL_IN_NW := "res://assets/art/board/terrain_wall_in_nw_dungeon.png" # wall_in_ne = 运行时镜像
|
|
||||||
const TEX_WALL_IN_SW := "res://assets/art/board/terrain_wall_in_sw_dungeon.png" # wall_in_se = 运行时镜像
|
|
||||||
const TEX_FLOOR_FMT := "res://assets/art/board/terrain_floor_dungeon_%d.png" # 1..5 变体
|
|
||||||
const TILE_FLOOR_VARIANTS := 5 # 地面瓦片变体数(词表 §一 floor_1..5)
|
const TILE_FLOOR_VARIANTS := 5 # 地面瓦片变体数(词表 §一 floor_1..5)
|
||||||
|
|
||||||
var _wrap: CenterContainer
|
var _wrap: CenterContainer
|
||||||
@@ -134,6 +124,7 @@ var _hero_dig_tw: Tween
|
|||||||
var _tiles := {} # 词表键 → Texture2D(镜像件已预翻转;装载见 _load_board_tiles)
|
var _tiles := {} # 词表键 → Texture2D(镜像件已预翻转;装载见 _load_board_tiles)
|
||||||
var _floor_tiles: Array[Texture2D] = [] # 地面瓦片五变体(下标 0..4 = floor_1..5)
|
var _floor_tiles: Array[Texture2D] = [] # 地面瓦片五变体(下标 0..4 = floor_1..5)
|
||||||
var _tiles_ok := false # false = 素材缺位/未装载——bg_sb 程序化平色原路径
|
var _tiles_ok := false # false = 素材缺位/未装载——bg_sb 程序化平色原路径
|
||||||
|
var _tiles_board_type := "dungeon" # 当前装载瓦片套的 board_type(rebuild 按板切换——T6 换色批)
|
||||||
|
|
||||||
var cell_size: float: # 只读属性(ui_layout_probe 断言面——旧私有名 _cell 的契约化出口)
|
var cell_size: float: # 只读属性(ui_layout_probe 断言面——旧私有名 _cell 的契约化出口)
|
||||||
get:
|
get:
|
||||||
@@ -255,6 +246,11 @@ func rebuild(board: BoardData, minesweeper) -> void:
|
|||||||
_r0[c] = true
|
_r0[c] = true
|
||||||
if _board == null:
|
if _board == null:
|
||||||
return
|
return
|
||||||
|
# 瓦片套随板图型切换(T6 换色批——board_gen 词表 v1.1 dungeon/underdark/deep_underdark;
|
||||||
|
# 同图型重复 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)
|
||||||
var stride := _cell + float(SEP)
|
var stride := _cell + float(SEP)
|
||||||
for y in _board.height:
|
for y in _board.height:
|
||||||
for x in _board.width:
|
for x in _board.width:
|
||||||
@@ -774,33 +770,37 @@ func _reset_breath() -> void:
|
|||||||
# 瓦片整批守卫装载(_init 单次调用):21 枚全成才启用——任一缺位按整批缺位处理(禁同板混渲染;
|
# 瓦片整批守卫装载(_init 单次调用):21 枚全成才启用——任一缺位按整批缺位处理(禁同板混渲染;
|
||||||
# fresh checkout 无素材目录 → 静默全回退)。缺位不 push_error——可选素材缺位是预期路径非故障
|
# fresh checkout 无素材目录 → 静默全回退)。缺位不 push_error——可选素材缺位是预期路径非故障
|
||||||
# (英雄批 load_tex 纪律同款,防冒烟刷屏)。镜像件运行时 flip_x 合成,不出文件。
|
# (英雄批 load_tex 纪律同款,防冒烟刷屏)。镜像件运行时 flip_x 合成,不出文件。
|
||||||
func _load_board_tiles() -> void:
|
func _load_board_tiles(board_type: String = "dungeon") -> void:
|
||||||
|
if board_type == "":
|
||||||
|
board_type = "dungeon"
|
||||||
var t := {
|
var t := {
|
||||||
"wall_center": UiKit.load_tex(TEX_WALL_CENTER),
|
"wall_center": UiKit.load_tex(TILE_WALL_FMT % ["wall_center", board_type]),
|
||||||
"wall_center_crack": UiKit.load_tex(TEX_WALL_CENTER_CRACK),
|
"wall_center_crack": UiKit.load_tex(TILE_WALL_FMT % ["wall_center_crack", board_type]),
|
||||||
"wall_center_speckle": UiKit.load_tex(TEX_WALL_CENTER_SPECKLE),
|
"wall_center_speckle": UiKit.load_tex(TILE_WALL_FMT % ["wall_center_speckle", board_type]),
|
||||||
"wall_n": UiKit.load_tex(TEX_WALL_N),
|
"wall_n": UiKit.load_tex(TILE_WALL_FMT % ["wall_n", board_type]),
|
||||||
"wall_s": UiKit.load_tex(TEX_WALL_S),
|
"wall_s": UiKit.load_tex(TILE_WALL_FMT % ["wall_s", board_type]),
|
||||||
"wall_w": UiKit.load_tex(TEX_WALL_W),
|
"wall_w": UiKit.load_tex(TILE_WALL_FMT % ["wall_w", board_type]),
|
||||||
"wall_nw": UiKit.load_tex(TEX_WALL_NW),
|
"wall_nw": UiKit.load_tex(TILE_WALL_FMT % ["wall_nw", board_type]),
|
||||||
"wall_sw": UiKit.load_tex(TEX_WALL_SW),
|
"wall_sw": UiKit.load_tex(TILE_WALL_FMT % ["wall_sw", board_type]),
|
||||||
"wall_hrun": UiKit.load_tex(TEX_WALL_HRUN),
|
"wall_hrun": UiKit.load_tex(TILE_WALL_FMT % ["wall_hrun", board_type]),
|
||||||
"wall_vrun": UiKit.load_tex(TEX_WALL_VRUN),
|
"wall_vrun": UiKit.load_tex(TILE_WALL_FMT % ["wall_vrun", board_type]),
|
||||||
"wall_end_n": UiKit.load_tex(TEX_WALL_END_N),
|
"wall_end_n": UiKit.load_tex(TILE_WALL_FMT % ["wall_end_n", board_type]),
|
||||||
"wall_end_s": UiKit.load_tex(TEX_WALL_END_S),
|
"wall_end_s": UiKit.load_tex(TILE_WALL_FMT % ["wall_end_s", board_type]),
|
||||||
"wall_end_w": UiKit.load_tex(TEX_WALL_END_W),
|
"wall_end_w": UiKit.load_tex(TILE_WALL_FMT % ["wall_end_w", board_type]),
|
||||||
"wall_lone": UiKit.load_tex(TEX_WALL_LONE),
|
"wall_lone": UiKit.load_tex(TILE_WALL_FMT % ["wall_lone", board_type]),
|
||||||
"wall_in_nw": UiKit.load_tex(TEX_WALL_IN_NW),
|
"wall_in_nw": UiKit.load_tex(TILE_WALL_FMT % ["wall_in_nw", board_type]),
|
||||||
"wall_in_sw": UiKit.load_tex(TEX_WALL_IN_SW),
|
"wall_in_sw": UiKit.load_tex(TILE_WALL_FMT % ["wall_in_sw", board_type]),
|
||||||
}
|
}
|
||||||
var floors: Array[Texture2D] = []
|
var floors: Array[Texture2D] = []
|
||||||
for i in range(1, TILE_FLOOR_VARIANTS + 1):
|
for i in range(1, TILE_FLOOR_VARIANTS + 1):
|
||||||
floors.append(UiKit.load_tex(TEX_FLOOR_FMT % i))
|
floors.append(UiKit.load_tex(TILE_FLOOR_FMT % [board_type, i]))
|
||||||
for k in t:
|
for k in t:
|
||||||
if t[k] == null:
|
if t[k] == null:
|
||||||
|
_request_dungeon_fallback(board_type)
|
||||||
return
|
return
|
||||||
for tex in floors:
|
for tex in floors:
|
||||||
if tex == null:
|
if tex == null:
|
||||||
|
_request_dungeon_fallback(board_type)
|
||||||
return
|
return
|
||||||
# 六镜像件(词表 §二:wall_e←wall_w / wall_ne←wall_nw / wall_se←wall_sw /
|
# 六镜像件(词表 §二: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;只水平翻转禁旋转
|
# wall_end_e←wall_end_w / wall_in_ne←wall_in_nw / wall_in_se←wall_in_sw;只水平翻转禁旋转
|
||||||
@@ -813,9 +813,17 @@ func _load_board_tiles() -> void:
|
|||||||
t["wall_in_se"] = _flip_tex(t["wall_in_sw"] as Texture2D)
|
t["wall_in_se"] = _flip_tex(t["wall_in_sw"] as Texture2D)
|
||||||
_tiles = t
|
_tiles = t
|
||||||
_floor_tiles = floors
|
_floor_tiles = floors
|
||||||
|
_tiles_board_type = board_type
|
||||||
_tiles_ok = true
|
_tiles_ok = true
|
||||||
|
|
||||||
|
|
||||||
|
# 整批回落(T6 换色批):请求套缺位 → 回落 dungeon 套(禁同板混渲染);dungeon 本身缺位 =
|
||||||
|
# 维持程序化平色回退原语义(_tiles_ok 恒 false,缺位不刷屏纪律不变)
|
||||||
|
func _request_dungeon_fallback(requested: String) -> void:
|
||||||
|
if requested != "dungeon":
|
||||||
|
_load_board_tiles("dungeon")
|
||||||
|
|
||||||
|
|
||||||
# 水平镜像贴图运行时合成(flip_h 语义):Image 级逐位翻转后重建 ImageTexture——CellUnit 零改动。
|
# 水平镜像贴图运行时合成(flip_h 语义):Image 级逐位翻转后重建 ImageTexture——CellUnit 零改动。
|
||||||
# VRAM 压缩源先解压(flip_x 不支持压缩格式);32×32 原尺寸逐位翻转,无 ±1px 取整问题
|
# VRAM 压缩源先解压(flip_x 不支持压缩格式);32×32 原尺寸逐位翻转,无 ±1px 取整问题
|
||||||
static func _flip_tex(tex: Texture2D) -> Texture2D:
|
static func _flip_tex(tex: Texture2D) -> Texture2D:
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ func _suites() -> Array:
|
|||||||
{"path": "res://tests/unit/battle_settlement/damage_controller_test.gd", "expect": 0},
|
{"path": "res://tests/unit/battle_settlement/damage_controller_test.gd", "expect": 0},
|
||||||
{"path": "res://tests/unit/contracts/system_contracts_test.gd", "expect": 0},
|
{"path": "res://tests/unit/contracts/system_contracts_test.gd", "expect": 0},
|
||||||
{"path": "res://tests/unit/presentation/ui_font_asset_test.gd", "expect": 0},
|
{"path": "res://tests/unit/presentation/ui_font_asset_test.gd", "expect": 0},
|
||||||
|
{"path": "res://tests/unit/presentation/terrain_tile_sets_test.gd", "expect": 0},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# tests/unit/presentation/terrain_tile_sets_test.gd — 瓦片换色机检(T6 卡交付:色相域抽检+套完整性)
|
||||||
|
# 运行:godot --headless --path game --script res://tests/unit/presentation/terrain_tile_sets_test.gd
|
||||||
|
# 事实源:board_gen.tres board_type 词表 v1.1(dungeon/underdark/deep_underdark——从表读值,禁写死);
|
||||||
|
# 色相域方向 = art-bible §4.3 三关方向(underdark 灰白蓝紫 / deep_underdark 蓝黑紫黑);
|
||||||
|
# 图底分离 = 圣经 §8.5(已开地面明度低于未开墙体)。
|
||||||
|
# 前置守卫:素材不入 git(用户裁定)——fresh checkout 无瓦片文件时本套件跳过(打印注记,不红);
|
||||||
|
# 源 dungeon 套在盘而换色套缺位 = 回归(换色脚本在库可复现)。
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const ConfigDBScript := preload("res://core/config_db.gd")
|
||||||
|
const BoardViewScript := preload("res://presentation/views/board_view.gd")
|
||||||
|
|
||||||
|
var _failures := 0
|
||||||
|
var _checks := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
var board_dir := ProjectSettings.globalize_path("res://assets/art/board/")
|
||||||
|
var probe_path := board_dir.path_join("terrain_wall_center_dungeon.png")
|
||||||
|
if not FileAccess.file_exists(probe_path):
|
||||||
|
print("[terrain_tile_sets_test] SKIP——素材工作区缺位(瓦片不入 git 纪律,fresh checkout 预期态)")
|
||||||
|
quit(0)
|
||||||
|
return
|
||||||
|
var db = ConfigDBScript.new()
|
||||||
|
var load_ok: bool = db.load_tables("res://data/tables", true)
|
||||||
|
_check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0)
|
||||||
|
if not load_ok:
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
# board_type 词表自 board_gen 行集推导(禁测试侧写死词表)
|
||||||
|
var types: Array = []
|
||||||
|
for row in db.board_gen.rows:
|
||||||
|
var bt := String(row.get("board_type", ""))
|
||||||
|
if bt != "" and not types.has(bt):
|
||||||
|
types.append(bt)
|
||||||
|
_check("前置:board_gen 词表非空(%d 型)" % types.size(), types.size() > 0)
|
||||||
|
_test_set_completeness(board_dir, types)
|
||||||
|
_test_hue_domains(board_dir)
|
||||||
|
if _failures > 0:
|
||||||
|
push_error("[terrain_tile_sets_test] %d/%d 项失败" % [_failures, _checks])
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
print("[terrain_tile_sets_test] all green (%d checks)" % _checks)
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(desc: String, ok: bool) -> void:
|
||||||
|
_checks += 1
|
||||||
|
if ok:
|
||||||
|
print(" [PASS] " + desc)
|
||||||
|
else:
|
||||||
|
_failures += 1
|
||||||
|
push_error(" [FAIL] " + desc)
|
||||||
|
|
||||||
|
|
||||||
|
# 套完整性:词表每个 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)
|
||||||
|
for bt in types:
|
||||||
|
var missing: Array = []
|
||||||
|
for w in wall_names:
|
||||||
|
var p := board_dir.path_join("terrain_%s_%s.png" % [String(w), String(bt)])
|
||||||
|
if not FileAccess.file_exists(p):
|
||||||
|
missing.append(String(w))
|
||||||
|
for i in range(1, variants + 1):
|
||||||
|
var pf := board_dir.path_join("terrain_floor_%s_%d.png" % [String(bt), i])
|
||||||
|
if not FileAccess.file_exists(pf):
|
||||||
|
missing.append("floor_%d" % i)
|
||||||
|
_check("图型 %s 瓦片套完整(16 墙 + %d 地面,缺=%s)" % [String(bt), variants, str(missing)], missing.is_empty())
|
||||||
|
|
||||||
|
|
||||||
|
# 色相域抽检:wall_center 上半幅(墙顶面)与 floor_1 全幅的均值色相/明度
|
||||||
|
func _test_hue_domains(board_dir: String) -> void:
|
||||||
|
var bands := {
|
||||||
|
"dungeon": [0.02, 0.16], # 暖石棕 ≈15°–58°
|
||||||
|
"underdark": [0.50, 0.72], # 灰白蓝紫 ≈180°–260°
|
||||||
|
"deep_underdark": [0.55, 0.78], # 蓝黑紫黑 ≈200°–280°
|
||||||
|
}
|
||||||
|
var wall_v := {}
|
||||||
|
var floor_v := {}
|
||||||
|
for bt in bands.keys():
|
||||||
|
var wall := _load_stats(board_dir, "terrain_wall_center_%s.png" % String(bt), true)
|
||||||
|
var floor_img := _load_stats(board_dir, "terrain_floor_%s_1.png" % String(bt), false)
|
||||||
|
if wall.is_empty() or floor_img.is_empty():
|
||||||
|
_check("图型 %s 抽检样本可读" % String(bt), false)
|
||||||
|
continue
|
||||||
|
wall_v[bt] = float(wall["v"])
|
||||||
|
floor_v[bt] = float(floor_img["v"])
|
||||||
|
var band: Array = bands[bt]
|
||||||
|
_check("图型 %s 墙顶均值色相在带 %s(实测 %.3f)" % [String(bt), str(band), float(wall["h"])],
|
||||||
|
float(wall["h"]) >= float(band[0]) and float(wall["h"]) <= float(band[1]))
|
||||||
|
_check("图型 %s 图底分离(墙顶明度 %.3f > 地面 %.3f)" % [String(bt), wall_v[bt], floor_v[bt]],
|
||||||
|
wall_v[bt] > floor_v[bt])
|
||||||
|
if wall_v.has("underdark") and wall_v.has("deep_underdark"):
|
||||||
|
_check("deep_underdark 墙顶更暗于 underdark(更深蓝黑方向)", float(wall_v["deep_underdark"]) < float(wall_v["underdark"]))
|
||||||
|
|
||||||
|
|
||||||
|
# 均值读数:hue 取饱和度 > 0.05 的像素(墨线/灰黑不影响色相均值);v 取全幅均值
|
||||||
|
func _load_stats(board_dir: String, fname: String, top_half: bool) -> Dictionary:
|
||||||
|
var p := board_dir.path_join(fname)
|
||||||
|
if not FileAccess.file_exists(p):
|
||||||
|
return {}
|
||||||
|
var img := Image.load_from_file(p)
|
||||||
|
if img == null:
|
||||||
|
return {}
|
||||||
|
if img.is_compressed():
|
||||||
|
img.decompress()
|
||||||
|
var w := img.get_width()
|
||||||
|
var h := img.get_height()
|
||||||
|
var y0 := 0
|
||||||
|
var y1 := h
|
||||||
|
if top_half:
|
||||||
|
y1 = h / 2 # 墙顶面 = 上半幅(立面暗带不参与色相均值)
|
||||||
|
var h_sum := 0.0
|
||||||
|
var v_sum := 0.0
|
||||||
|
var n_h := 0
|
||||||
|
var n_v := 0
|
||||||
|
for y in range(y0, y1):
|
||||||
|
for x in range(w):
|
||||||
|
var c := img.get_pixel(x, y)
|
||||||
|
v_sum += c.v
|
||||||
|
n_v += 1
|
||||||
|
if c.s > 0.05:
|
||||||
|
h_sum += c.h
|
||||||
|
n_h += 1
|
||||||
|
if n_v == 0 or n_h == 0:
|
||||||
|
return {}
|
||||||
|
return {"h": h_sum / n_h, "v": v_sum / n_v}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bgc7dabk26is
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# tools/recolor_terrain_tiles.py — 高墙/地面瓦片程序换色(T6,M-1 迭代 1)
|
||||||
|
# 用法:
|
||||||
|
# python tools/recolor_terrain_tiles.py # 产 underdark + deep_underdark 两套到源目录
|
||||||
|
# python tools/recolor_terrain_tiles.py --palettes underdark
|
||||||
|
# 事实源:
|
||||||
|
# - 源瓦片 = game/assets/art/board/terrain_*_dungeon.png(v9.3 定稿,active.md 台账 164;
|
||||||
|
# 素材不入 git——本脚本读盘上现件,勿删活件)
|
||||||
|
# - 目标色表 = _tools/gen_terrain_walls.py PALETTES(归档 归档-terrain-walls-2026-09-26.zip 内
|
||||||
|
# 活件;underdark/deep_underdark 槽值系圣经 §4.3 三关方向、三关换色证明条在案的已过闸色表)
|
||||||
|
# 方法:槽位家族保持的重映射——源图每唯一色 → 最近 dungeon 锚槽(RGB 距离)定家族(墙顶/立面/
|
||||||
|
# 地面/雷坑);家族内按亮度秩序线性映射到目标色表同家族槽列。亮度秩序 = 图底分离与结构读数
|
||||||
|
# 的承载面,逐像素保持;输出重排唯一色数与源一致(量化纪律)。确定性零随机。
|
||||||
|
from __future__ import annotations
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# ── dungeon 锚槽(v9.3 生成器同款——家族归属判定用)──
|
||||||
|
DUNGEON = {
|
||||||
|
"OUT": (43, 34, 30), "D2": (76, 61, 49), "D1": (122, 101, 78),
|
||||||
|
"M": (139, 117, 91), "H1": (156, 133, 103), "H2": (181, 155, 119),
|
||||||
|
"F_OUT": (26, 21, 19), "F3": (36, 29, 26), "F2": (49, 39, 34),
|
||||||
|
"F1": (63, 51, 44), "FH": (88, 71, 56),
|
||||||
|
"G_BASE": (88, 66, 50), "G_LIT": (108, 76, 54), "G_DARK": (75, 61, 50),
|
||||||
|
"G_CHIP": (134, 82, 52), "G_STONE": (114, 98, 82), "G_STONE_D": (84, 72, 60),
|
||||||
|
"P_HOLE": (17, 25, 24), "P_DEEP": (12, 17, 17), "P_GLINT": (33, 58, 48),
|
||||||
|
"P_GLINT2": (45, 70, 56), "P_RIM_D": (40, 30, 26), "P_RIM_L": (129, 92, 65),
|
||||||
|
}
|
||||||
|
FAMILY_OF = {}
|
||||||
|
for s in ["OUT", "D2", "D1", "M", "H1", "H2"]:
|
||||||
|
FAMILY_OF[s] = "wall"
|
||||||
|
for s in ["F_OUT", "F3", "F2", "F1", "FH"]:
|
||||||
|
FAMILY_OF[s] = "facade"
|
||||||
|
for s in ["G_BASE", "G_LIT", "G_DARK", "G_CHIP", "G_STONE", "G_STONE_D"]:
|
||||||
|
FAMILY_OF[s] = "floor"
|
||||||
|
for s in ["P_HOLE", "P_DEEP", "P_GLINT", "P_GLINT2", "P_RIM_D", "P_RIM_L"]:
|
||||||
|
FAMILY_OF[s] = "crater"
|
||||||
|
|
||||||
|
PALETTES = {
|
||||||
|
"underdark": {
|
||||||
|
"OUT": (36, 38, 50), "D2": (72, 78, 96), "D1": (108, 116, 138),
|
||||||
|
"M": (138, 145, 166), "H1": (162, 170, 190), "H2": (186, 193, 210),
|
||||||
|
"F_OUT": (20, 22, 30), "F3": (30, 33, 44), "F2": (42, 46, 60),
|
||||||
|
"F1": (56, 61, 78), "FH": (80, 87, 108),
|
||||||
|
"G_BASE": (62, 66, 80), "G_LIT": (76, 82, 98), "G_DARK": (52, 55, 68),
|
||||||
|
"G_CHIP": (96, 84, 122), "G_STONE": (84, 90, 108), "G_STONE_D": (62, 66, 80),
|
||||||
|
"P_HOLE": (14, 15, 22), "P_DEEP": (9, 10, 15), "P_GLINT": (38, 40, 66),
|
||||||
|
"P_GLINT2": (52, 56, 86), "P_RIM_D": (32, 34, 44), "P_RIM_L": (110, 116, 140),
|
||||||
|
},
|
||||||
|
"deep_underdark": {
|
||||||
|
"OUT": (24, 25, 36), "D2": (48, 51, 70), "D1": (74, 79, 104),
|
||||||
|
"M": (98, 104, 132), "H1": (124, 131, 160), "H2": (150, 158, 186),
|
||||||
|
"F_OUT": (13, 13, 20), "F3": (20, 21, 32), "F2": (29, 31, 46),
|
||||||
|
"F1": (40, 43, 62), "FH": (58, 63, 88),
|
||||||
|
"G_BASE": (46, 48, 64), "G_LIT": (62, 66, 86), "G_DARK": (38, 40, 54),
|
||||||
|
"G_CHIP": (88, 72, 120), "G_STONE": (88, 94, 116), "G_STONE_D": (60, 64, 82),
|
||||||
|
"P_HOLE": (8, 8, 14), "P_DEEP": (5, 5, 9), "P_GLINT": (28, 28, 52),
|
||||||
|
"P_GLINT2": (40, 42, 70), "P_RIM_D": (22, 23, 34), "P_RIM_L": (84, 90, 116),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _lum(c):
|
||||||
|
return 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
|
||||||
|
|
||||||
|
|
||||||
|
def _dist(a, b):
|
||||||
|
return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2
|
||||||
|
|
||||||
|
|
||||||
|
def _lerp(a, b, t):
|
||||||
|
return tuple(int(round(a[i] + (b[i] - a[i]) * t)) for i in range(3))
|
||||||
|
|
||||||
|
|
||||||
|
def build_map(src_colors, target_palette):
|
||||||
|
"""源唯一色 → 目标色。家族归属 = 最近 dungeon 锚;家族内按亮度秩序映射到目标同家族槽列。"""
|
||||||
|
fam_anchors = {} # family -> [(lum, slot, rgb)] 升序
|
||||||
|
for slot, fam in FAMILY_OF.items():
|
||||||
|
fam_anchors.setdefault(fam, []).append((_lum(DUNGEON[slot]), slot, DUNGEON[slot]))
|
||||||
|
for fam in fam_anchors:
|
||||||
|
fam_anchors[fam].sort()
|
||||||
|
tgt_ramps = {}
|
||||||
|
for fam in fam_anchors:
|
||||||
|
slots = [( _lum(target_palette[s]), s, target_palette[s]) for s in FAMILY_OF if FAMILY_OF[s] == fam]
|
||||||
|
slots.sort()
|
||||||
|
tgt_ramps[fam] = slots
|
||||||
|
mapping = {}
|
||||||
|
fam_members = {}
|
||||||
|
for c in src_colors:
|
||||||
|
best_fam, best = None, None
|
||||||
|
for fam, anchors in fam_anchors.items():
|
||||||
|
for _, slot, rgb in anchors:
|
||||||
|
d = _dist(c, rgb)
|
||||||
|
if best is None or d < best:
|
||||||
|
best, best_fam = d, fam
|
||||||
|
fam_members.setdefault(best_fam, []).append(c)
|
||||||
|
for fam, members in fam_members.items():
|
||||||
|
members.sort(key=_lum)
|
||||||
|
ramp = tgt_ramps[fam]
|
||||||
|
n_s, n_t = len(members), len(ramp)
|
||||||
|
for i, c in enumerate(members):
|
||||||
|
t = 0.0 if n_s <= 1 else i / (n_s - 1)
|
||||||
|
pos = t * (n_t - 1)
|
||||||
|
lo = int(pos)
|
||||||
|
hi = min(lo + 1, n_t - 1)
|
||||||
|
mapping[c] = _lerp(ramp[lo][2], ramp[hi][2], pos - lo)
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def remap_tile(src_path, out_path, target_palette):
|
||||||
|
im = Image.open(src_path).convert("RGBA")
|
||||||
|
px = im.load()
|
||||||
|
w, h = im.size
|
||||||
|
colors = sorted({px[x, y][:3] for y in range(h) for x in range(w)})
|
||||||
|
mapping = build_map(colors, target_palette)
|
||||||
|
for y in range(h):
|
||||||
|
for x in range(w):
|
||||||
|
r, g, b, a = px[x, y]
|
||||||
|
nr, ng, nb = mapping[(r, g, b)]
|
||||||
|
px[x, y] = (nr, ng, nb, a)
|
||||||
|
im.save(out_path)
|
||||||
|
return len(colors)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
here = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
default_board = os.path.normpath(os.path.join(here, "..", "game", "assets", "art", "board"))
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--src", default=default_board)
|
||||||
|
ap.add_argument("--out", default=default_board)
|
||||||
|
ap.add_argument("--palettes", default="underdark,deep_underdark")
|
||||||
|
args = ap.parse_args()
|
||||||
|
# 墙瓦片 = terrain_*_dungeon.png;地板变体 = terrain_floor_dungeon_<i>.png(数字后缀——词表 §一)
|
||||||
|
import re
|
||||||
|
pat = re.compile(r"^terrain_.*_dungeon(_\d+)?\.png$")
|
||||||
|
sources = sorted(f for f in os.listdir(args.src) if pat.match(f))
|
||||||
|
if not sources:
|
||||||
|
print("[recolor] 源目录无 terrain_*_dungeon* 瓦片——素材缺位(不入 git 纪律),零输出", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
for pal in args.palettes.split(","):
|
||||||
|
P = PALETTES[pal]
|
||||||
|
n_out = 0
|
||||||
|
for f in sources:
|
||||||
|
out_name = f.replace("_dungeon", "_%s" % pal)
|
||||||
|
n = remap_tile(os.path.join(args.src, f), os.path.join(args.out, out_name), P)
|
||||||
|
n_out += 1
|
||||||
|
print("[recolor] %s <- %s(唯一色 %d)" % (out_name, f, n))
|
||||||
|
print("[recolor] %s:%d 枚完成" % (pal, n_out))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in new issue
Block a user