第 1 卷第 1 章:1–9 年

This commit is contained in:
Chen Yi
2026-10-05 17:30:11 +08:00
parent c56394afdd
commit 3468433c9a
34 changed files with 2564 additions and 4 deletions
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# 把本项目的 DFHack 脚本部署进游戏目录,并写入开机自动运行的 init 片段。
# scripts/deploy-dfhack.sh /mnt/c/Users/23518/DwarfFortress/df_53_16_win
set -euo pipefail
DF_DIR="${1:?用法: deploy-dfhack.sh <DF 目录>}"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SRC="$HERE/scripts/dfhack/dfannals"
[[ -d "$DF_DIR" ]] || { echo "游戏目录不存在: $DF_DIR" >&2; exit 1; }
[[ -d "$DF_DIR/hack" ]] || { echo "该目录里没有 hack/,DFHack 未安装?" >&2; exit 1; }
DEST="$DF_DIR/hack/scripts/dfannals"
mkdir -p "$DEST"
cp -v "$SRC"/*.lua "$DEST/"
INIT_DIR="$DF_DIR/dfhack-config/init"
mkdir -p "$INIT_DIR"
cat > "$INIT_DIR/dfannals.init" <<'EOF'
# 由 df-annals 项目部署。默认只跑观测脚本;需要全自动时改成 dfannals/drive。
dfannals/observe
EOF
echo "已部署到:$DEST"
echo "init 片段:$INIT_DIR/dfannals.init"
ls -1 "$DEST"
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""从 DFHack 文本截图中按文字定位控件,并生成点击坐标。
配合 dfannals/screentext 与 dfannals/click 使用:
1) dfhack-run dfannals/screentext
2) scripts/df-screen.py click --label "History length" --text "250 years"
屏幕文本文件里第 1 行是表头,因此 屏幕 y = 文件行号 - 2。
坐标为 0 起始的字符网格坐标,与 dfannals/click 一致。
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
SCREEN = Path("/mnt/c/Users/23518/DwarfFortress/dfannals-screen.txt")
def read_screen(path: Path) -> list[str]:
if not path.is_file():
sys.exit(f"找不到截图文件:{path}")
raw = path.read_text(encoding="utf-8", errors="replace").splitlines()
return raw[1:] # 去掉 "# focus=..." 表头,使下标 = 屏幕 y
def char_index(line: str, needle: str) -> int:
return line.find(needle)
def find_row(lines: list[str], needle: str, start: int = 0) -> int:
for i in range(start, len(lines)):
if needle in lines[i]:
return i
return -1
def cmd_list(args: argparse.Namespace) -> int:
lines = read_screen(args.file)
for y, line in enumerate(lines):
if line.strip():
print(f"{y:3} | {line.rstrip()}")
return 0
def cmd_click(args: argparse.Namespace) -> int:
lines = read_screen(args.file)
start = 0
if args.label:
label_row = find_row(lines, args.label)
if label_row < 0:
sys.exit(f"界面上找不到标签:{args.label}")
start = label_row
row = find_row(lines, args.text, start)
if row < 0:
sys.exit(f"找不到文字:{args.text}" + (f"(在 {args.label} 之下)" if args.label else ""))
line = lines[row]
idx = char_index(line, args.text)
x = idx + len(args.text) // 2
y = row
print(f"{x} {y}")
if args.verbose:
print(f"# 第 {row} 行,列 {idx},点击中心 ({x}, {y})", file=sys.stderr)
return 0
def cmd_where(args: argparse.Namespace) -> int:
lines = read_screen(args.file)
for pattern in args.patterns:
row = find_row(lines, pattern)
if row < 0:
print(f"{pattern}: 未找到")
continue
idx = char_index(lines[row], pattern)
print(f"{pattern}: x={idx + len(pattern) // 2} y={row}")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="DF 文本截图定位工具")
parser.add_argument("--file", type=Path, default=SCREEN)
sub = parser.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("list", help="列出非空行(带屏幕 y 坐标)")
p.set_defaults(func=cmd_list)
p = sub.add_parser("click", help="输出某段文字的中心坐标")
p.add_argument("--label", help="先定位这个标签,再在它下面找目标文字")
p.add_argument("--text", required=True, help="要点击的文字")
p.add_argument("-v", "--verbose", action="store_true")
p.set_defaults(func=cmd_click)
p = sub.add_parser("where", help="批量定位若干文字")
p.add_argument("patterns", nargs="+")
p.set_defaults(func=cmd_where)
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
+27
View File
@@ -0,0 +1,27 @@
--@ module = true
-- 向当前界面发送按键。用法:dfhack-run "dfannals/act SELECT"
-- 多个按键按顺序发送:dfhack-run "dfannals/act STANDARDSCROLL_DOWN SELECT"
local gui = require('gui')
local args = {...}
local scr = dfhack.gui.getCurViewscreen()
local sent = {}
for _, name in ipairs(args) do
local key = df.interface_key[name]
if key then
scr:feed_key(key)
sent[#sent + 1] = name
else
sent[#sent + 1] = '?未知按键:' .. name
end
end
local f = io.open('dfannals-act.log', 'a')
if f then
f:write(string.format('%s\t%s\tfocus=%s\n', os.date('%H:%M:%S'),
table.concat(sent, ' '), table.concat(dfhack.gui.getCurFocus(), '/')))
f:close()
end
print('sent: ' .. table.concat(sent, ' '))
+28
View File
@@ -0,0 +1,28 @@
--@ module = true
-- 在窗口坐标 (x, y) 模拟一次左键点击。用法:dfhack-run "dfannals/click 160 40"
-- 方式与 DFHack 自带测试工具一致:设置鼠标位置后 simulateInput('_MOUSE_L')。
local gui = require('gui')
local args = {...}
local x, y = tonumber(args[1]), tonumber(args[2])
if not x or not y then
print('用法: dfannals/click <x> <y>')
return
end
df.global.gps.mouse_x = math.floor(x)
df.global.gps.precise_mouse_x = math.floor(x) * df.global.gps.tile_pixel_x
df.global.gps.mouse_y = math.floor(y)
df.global.gps.precise_mouse_y = math.floor(y) * df.global.gps.tile_pixel_y
local scr = dfhack.gui.getCurViewscreen()
gui.simulateInput(scr, '_MOUSE_L')
local f = io.open('dfannals-act.log', 'a')
if f then
f:write(string.format('%s\tclick %d,%d\tfocus=%s\n', os.date('%H:%M:%S'), x, y,
table.concat(dfhack.gui.getCurFocus(), '/')))
f:close()
end
print(string.format('clicked %d,%d', x, y))
+57
View File
@@ -0,0 +1,57 @@
--@ module = true
-- 把当前界面的字段结构导出到 dfannals-dump.txt,用于摸清 UI 元素。
-- 用法:dfhack-run "dfannals/dump"
local MAX_ITEMS = 400
local function is_scalar(v)
local t = type(v)
return t == 'number' or t == 'string' or t == 'boolean'
end
local function describe(obj, max_depth)
local out = {}
local function rec(o, prefix, depth)
if depth > max_depth or #out > MAX_ITEMS then return end
local ok, iter, state, ctrl = pcall(pairs, o)
if not ok then return end
for k, v in iter, state, ctrl do
if #out > MAX_ITEMS then return end
local key = tostring(k)
if is_scalar(v) then
out[#out + 1] = string.format('%s%s = %s', prefix, key, tostring(v))
else
local t = type(v)
out[#out + 1] = string.format('%s%s <%s>', prefix, key, t)
if t == 'table' or t == 'userdata' then
rec(v, prefix .. ' ', depth + 1)
end
end
end
end
rec(obj, '', 1)
return out
end
local f = io.open('dfannals-dump.txt', 'w')
if not f then return end
local function write(line) f:write(line .. '\n') end
write('# focus=' .. table.concat(dfhack.gui.getCurFocus(), '/'))
write('# date=' .. os.date('%Y-%m-%d %H:%M:%S'))
local scr = dfhack.gui.getCurViewscreen()
local depth = 0
while scr and depth < 3 do
write('==== 屏幕层 ' .. depth .. ' ====')
local ok, lines = pcall(describe, scr, 2)
if ok then
for _, l in ipairs(lines) do write(l) end
else
write('(失败: ' .. tostring(lines) .. ')')
end
scr = scr.parent
depth = depth + 1
end
f:close()
+39
View File
@@ -0,0 +1,39 @@
--@ module = true
-- 观测模式:把界面焦点变化持续追加到 dfannals-observe.log。
-- 用途:发现"新建世界 → 进入 legends → 导出"之间的真实界面序列。
local repeatUtil = require('repeat-util')
local LOG = 'dfannals-observe.log'
local last = nil
local ticks = 0
local function focus_string()
local ok, focus = pcall(dfhack.gui.getCurFocus)
if not ok or not focus then return '?' end
return table.concat(focus, '/')
end
function tick()
ticks = ticks + 1
local fs = focus_string()
if fs ~= last then
last = fs
local f = io.open(LOG, 'a')
if f then
f:write(string.format('%s\t%s\n', os.date('%H:%M:%S'), fs))
f:close()
end
end
end
function start()
repeatUtil.scheduleEvery('dfannals-observe', 10, tick)
local f = io.open(LOG, 'a')
if f then
f:write('--- observe start ' .. os.date('%Y-%m-%d %H:%M:%S') .. '\n')
f:close()
end
end
start()
+56
View File
@@ -0,0 +1,56 @@
--@ module = true
-- 把当前界面状态写入 dfannals-probe.txt,供 WSL 侧读取。
-- 用法(在 WSL 里):"Dwarf Fortress.exe" 所在目录的 dfhack-run.exe dfannals/probe
local function safe_type(obj)
if not obj then return '?' end
local ok, t = pcall(function() return obj._type end)
if ok and t then return tostring(t) end
return '?'
end
local function viewstack()
local out = {}
local scr = dfhack.gui.getCurViewscreen()
local depth = 0
while scr and depth < 15 do
out[#out + 1] = string.format('[%d] %s', depth, safe_type(scr))
local ok, parent = pcall(function() return scr.parent end)
if not ok then break end
scr = parent
depth = depth + 1
end
return out
end
local function world_info()
local lines = {}
local ok, folder = pcall(dfhack.world.ReadWorldFolder)
lines[#lines + 1] = 'world_folder=' .. (ok and tostring(folder) or '?')
local ok2, year = pcall(dfhack.world.ReadCurrentYear)
lines[#lines + 1] = 'current_year=' .. (ok2 and tostring(year) or '?')
local ok3, legends = pcall(dfhack.world.isLegends)
lines[#lines + 1] = 'is_legends=' .. (ok3 and tostring(legends) or '?')
local ok4, fort = pcall(dfhack.world.isFortressMode)
lines[#lines + 1] = 'is_fortress=' .. (ok4 and tostring(fort) or '?')
local ok5, name = pcall(function() return dfhack.translation.translateName(df.global.world.world_data.name) end)
lines[#lines + 1] = 'world_name=' .. (ok5 and tostring(name) or '?')
return lines
end
function dump()
local lines = {}
local ok, focus = pcall(dfhack.gui.getCurFocus)
lines[#lines + 1] = 'focus=' .. ((ok and focus) and table.concat(focus, '/') or '?')
for _, l in ipairs(world_info()) do lines[#lines + 1] = l end
lines[#lines + 1] = 'viewstack:'
for _, l in ipairs(viewstack()) do lines[#lines + 1] = ' ' .. l end
local f = io.open('dfannals-probe.txt', 'w')
if f then
f:write(table.concat(lines, '\n') .. '\n')
f:close()
end
end
dump()
+49
View File
@@ -0,0 +1,49 @@
--@ module = true
-- 遍历当前界面的控件树,导出控件名与矩形坐标,用于定位可点击目标。
-- 用法:dfhack-run "dfannals/widgets"
local MAX_DEPTH = 8
local MAX_LINES = 600
local function rect_str(w)
local ok, r = pcall(function() return w.rect end)
if not ok or not r then return '' end
local function g(k)
local ok2, v = pcall(function() return r[k] end)
return ok2 and tostring(v) or '?'
end
return string.format('rect=(%s,%s)-(%s,%s)', g('x1'), g('y1'), g('x2'), g('y2'))
end
local out = {}
local function walk(w, depth)
if not w or depth > MAX_DEPTH or #out > MAX_LINES then return end
local ok, name = pcall(function() return w.name end)
local nm = ok and tostring(name) or ''
local txt = ''
local ok3, t = pcall(function() return w.text end)
if ok3 and t then txt = tostring(t) end
out[#out + 1] = string.format('%s%s %s %s', string.rep(' ', depth), nm, rect_str(w),
txt ~= '' and ('text=' .. txt) or '')
local ok2, children = pcall(function() return w.children end)
if ok2 and children then
for _, c in ipairs(children) do walk(c, depth + 1) end
end
end
local f = io.open('dfannals-widgets.txt', 'w')
if not f then return end
f:write('# focus=' .. table.concat(dfhack.gui.getCurFocus(), '/') .. '\n')
local sw, sh = dfhack.screen.getWindowSize()
f:write(string.format('# window=%dx%d\n', sw, sh))
local scr = dfhack.gui.getCurViewscreen()
local ok, widgets = pcall(function() return scr.widgets end)
if ok and widgets then
walk(widgets, 0)
else
out[#out + 1] = '(该界面没有 widgets 字段)'
end
for _, l in ipairs(out) do f:write(l .. '\n') end
f:close()
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# 在 Windows 侧安装 矮人要塞 免费版 + DFHack(经 WSL 的 /mnt/c 操作,免管理员权限)
#
# scripts/install-df.sh 安装/校验
# scripts/install-df.sh --download 重新下载缺的文件
set -euo pipefail
WIN_USER="${WIN_USER:-23518}"
ROOT="/mnt/c/Users/${WIN_USER}/DwarfFortress"
DF_ZIP="df_53_16_win.zip"
DFHACK_ZIP="dfhack-53.16-r2-Windows-64bit.zip"
DF_URL="https://www.bay12games.com/dwarves/${DF_ZIP}"
DFHACK_URL="https://github.com/DFHack/dfhack/releases/download/53.16-r2/${DFHACK_ZIP}"
mkdir -p "$ROOT"
cd "$ROOT"
download() {
local url="$1" out="$2"
echo "== 下载 $out"
for _ in $(seq 1 300); do
wget -c -q --timeout=30 --tries=2 -O "$out" "$url" && return 0
sleep 5
done
echo "下载失败:$url" >&2
return 1
}
if [[ "${1:-}" == "--download" ]]; then
[[ -s "$DF_ZIP" ]] || download "$DF_URL" "$DF_ZIP"
[[ -s "$DFHACK_ZIP" ]] || download "$DFHACK_URL" "$DFHACK_ZIP"
fi
for f in "$DF_ZIP" "$DFHACK_ZIP"; do
[[ -s "$f" ]] || { echo "缺少 $f(先跑 --download)" >&2; exit 1; }
unzip -t "$f" >/dev/null || { echo "$f 已损坏" >&2; exit 1; }
done
echo "== 解压游戏本体"
DF_DIR="$(unzip -Z1 "$DF_ZIP" | head -1 | cut -d/ -f1)"
if [[ ! -d "$DF_DIR" ]]; then
unzip -q -o "$DF_ZIP"
fi
echo " 游戏目录:$ROOT/$DF_DIR"
echo "== 解压 DFHack(覆盖进游戏目录)"
unzip -q -o "$DFHACK_ZIP" -d "$DF_DIR"
echo "== 校验"
ls -1 "$DF_DIR" | head -20
if [[ -f "$DF_DIR/Dwarf Fortress.exe" ]]; then
echo " ✓ 启动器存在:Dwarf Fortress.exe"
else
echo " ✗ 未找到 Dwarf Fortress.exe" >&2
fi
if [[ -d "$DF_DIR/dfhack-config" && -d "$DF_DIR/hack" ]]; then
echo " ✓ DFHack 已就位(hack/ + dfhack-config/)"
else
echo " ✗ DFHack 目录缺失" >&2
fi
echo "$ROOT/$DF_DIR"