Files
gplan-2/game/presentation/views/board_view.gd
T

520 lines
22 KiB
GDScript
Raw Blame History

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