Update Healer Man server
This commit is contained in:
1 parent
9c5fb4c614
commit
bd073fb00f
113 files changed
+4902
-273
No files matched your search
@@ -0,0 +1,244 @@
|
||||
"""Import client minimap tiles and calibrated navigation plans for RuneWaker dungeons.
|
||||
|
||||
Requires Pillow and NumPy. RUNEWAKER_ROOT may point at another extracted client.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = Path(os.environ.get("RUNEWAKER_ROOT", ROOT.parent / "Runewaker")) / "Resource" / "texture" / "minimap"
|
||||
RECIPES = ROOT / "scripts/runewaker-pipeline/recipes"
|
||||
OUTPUT = ROOT / "public/assets/game/dungeons"
|
||||
CATALOG = ROOT / "src/game/generated/runewakerDungeonMaps.generated.json"
|
||||
IMAGE_SIZE = (1002, 668)
|
||||
SOURCE_PIXELS_PER_UNIT = 0.12
|
||||
ALIASES = {
|
||||
"ystra-labyrinth-boss1": "tiledgn_hybora_labyrinth_01",
|
||||
"ystra-labyrinth-boss2": "tiledgn_hybora_labyrinth_02",
|
||||
"ystra-labyrinth-boss3": "tiledgn_hybora_labyrinth_03",
|
||||
}
|
||||
|
||||
|
||||
def read_json(path):
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def runtime_visual_checksum(slug):
|
||||
file = (ROOT / "src/game/dungeons/forsakenAbbey.ts" if slug == "forsaken-abbey"
|
||||
else ROOT / "src/game/generated/runewakerDungeonDefinitions.generated.ts")
|
||||
source = file.read_text(encoding="utf-8")
|
||||
identifier = f'id: "{slug}"' if slug == "forsaken-abbey" else f'"id": "{slug}"'
|
||||
start = source.find(identifier)
|
||||
if start < 0:
|
||||
raise ValueError(f"Missing runtime definition for {slug}")
|
||||
match = re.search(r'\bvisual\b[\s\S]*?\bchecksum["\s:]*"([0-9a-f]{64})"', source[start:])
|
||||
if not match:
|
||||
raise ValueError(f"Missing runtime visual checksum for {slug}")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def navigation_mesh(slug):
|
||||
data = (ROOT / f"src/assets/game/dungeons/{slug}/{slug}-navigation.glb").read_bytes()
|
||||
json_length = struct.unpack_from("<I", data, 12)[0]
|
||||
gltf = json.loads(data[20:20 + json_length])
|
||||
binary_start = 20 + ((json_length + 3) & ~3) + 8
|
||||
|
||||
def accessor(index):
|
||||
item = gltf["accessors"][index]
|
||||
view = gltf["bufferViews"][item["bufferView"]]
|
||||
kind = {5126: "<f4", 5125: "<u4", 5123: "<u2"}[item["componentType"]]
|
||||
width = {"VEC3": 3, "SCALAR": 1}[item["type"]]
|
||||
offset = binary_start + view.get("byteOffset", 0) + item.get("byteOffset", 0)
|
||||
return np.frombuffer(data, dtype=kind, count=item["count"] * width, offset=offset).reshape(-1, width)
|
||||
|
||||
primitive = gltf["meshes"][0]["primitives"][0]
|
||||
return (accessor(primitive["attributes"]["POSITION"]).astype(np.float64),
|
||||
accessor(primitive["indices"]).reshape(-1, 3))
|
||||
|
||||
|
||||
def placement_for(slug):
|
||||
metadata = read_json(ROOT / f"src/assets/game/dungeons/{slug}/import-metadata.json")
|
||||
source = metadata["source"]
|
||||
return source.get("wdbReport", source.get("wdb", {}))["primaryPlacement"]
|
||||
|
||||
|
||||
def source_points(local_vertices, placement):
|
||||
points = local_vertices * np.array([10, 10, -10], dtype=np.float64)
|
||||
quaternion = np.array(placement["rotation"], dtype=np.float64)
|
||||
cross = np.cross(quaternion[:3], points)
|
||||
points = points + 2 * (quaternion[3] * cross + np.cross(quaternion[:3], cross))
|
||||
return points * np.array(placement["scale"]) + np.array(placement["translation"])
|
||||
|
||||
|
||||
def client_tiles(name):
|
||||
directory = SOURCE / name / "0"
|
||||
tiles = {}
|
||||
for file in directory.glob("minimap_*.dds"):
|
||||
match = re.fullmatch(r"minimap_(\d+)_(\d+)\.dds", file.name)
|
||||
if match:
|
||||
tiles[tuple(map(int, match.groups()))] = file
|
||||
if not tiles:
|
||||
return None
|
||||
size = ((max(x for x, _ in tiles) + 1) * 512, (max(y for _, y in tiles) + 1) * 512)
|
||||
image = Image.new("RGBA", size)
|
||||
for (x, y), file in sorted(tiles.items()):
|
||||
image.paste(Image.open(file).convert("RGBA"), (x * 512, y * 512))
|
||||
return image, sorted(tiles.values())
|
||||
|
||||
|
||||
def artwork_mask(image, size):
|
||||
reduced = image.resize(size, Image.Resampling.BOX)
|
||||
rgb = np.asarray(reduced.convert("RGB")).astype(np.int16)
|
||||
background = np.asarray(image.convert("RGB").getpixel((0, 0)), dtype=np.int16)
|
||||
opaque = np.asarray(reduced.getchannel("A")) > 8
|
||||
visible = (np.max(np.abs(rgb - background), axis=2) > 18) & opaque
|
||||
return np.asarray(Image.fromarray(visible.astype("uint8") * 255).filter(ImageFilter.MaxFilter(5))) > 8
|
||||
|
||||
|
||||
def navmesh_mask(points, faces, size):
|
||||
image = Image.new("L", size)
|
||||
draw = ImageDraw.Draw(image)
|
||||
for face in faces:
|
||||
draw.polygon([tuple(points[index]) for index in face], fill=255)
|
||||
return np.asarray(image) > 0
|
||||
|
||||
|
||||
def calibrate(image, source_vertices, faces):
|
||||
factor = min(1, 512 / max(image.size))
|
||||
size = (round(image.width * factor), round(image.height * factor))
|
||||
art = artwork_mask(image, size)
|
||||
oriented = source_vertices[:, [0, 2]] * np.array([1, -1])
|
||||
minimum = oriented.min(axis=0)
|
||||
points = (oriented - minimum) * SOURCE_PIXELS_PER_UNIT * factor
|
||||
if points[:, 0].max() >= size[0] or points[:, 1].max() >= size[1]:
|
||||
return None
|
||||
floor = navmesh_mask(points, faces, size)
|
||||
if floor.sum() < 20 or art.sum() < 20:
|
||||
return None
|
||||
correlation = np.fft.irfft2(
|
||||
np.fft.rfft2(art.astype(np.float32)) * np.conj(np.fft.rfft2(floor.astype(np.float32))),
|
||||
s=art.shape,
|
||||
)
|
||||
valid = correlation[:size[1] - int(points[:, 1].max()), :size[0] - int(points[:, 0].max())]
|
||||
row, column = np.unravel_index(np.argmax(valid), valid.shape)
|
||||
confidence = float(valid[row, column]) / float(floor.sum())
|
||||
return minimum, np.array([column / factor, row / factor]), confidence
|
||||
|
||||
|
||||
def original_map(image, source_vertices, faces, calibrated):
|
||||
minimum, shift, confidence = calibrated
|
||||
points = ((source_vertices[:, [0, 2]] * np.array([1, -1]) - minimum)
|
||||
* SOURCE_PIXELS_PER_UNIT + shift)
|
||||
left = max(0, int(np.floor(points[:, 0].min() - 60)))
|
||||
top = max(0, int(np.floor(points[:, 1].min() - 60)))
|
||||
right = min(image.width, int(np.ceil(points[:, 0].max() + 60)))
|
||||
bottom = min(image.height, int(np.ceil(points[:, 1].max() + 60)))
|
||||
if right <= left or bottom <= top:
|
||||
raise ValueError("Calibrated minimap crop is empty")
|
||||
cropped = image.crop((left, top, right, bottom))
|
||||
scale = min(IMAGE_SIZE[0] / cropped.width, IMAGE_SIZE[1] / cropped.height)
|
||||
resized = cropped.resize((round(cropped.width * scale), round(cropped.height * scale)), Image.Resampling.LANCZOS)
|
||||
output = Image.new("RGBA", IMAGE_SIZE, (10, 14, 17, 255))
|
||||
origin = ((IMAGE_SIZE[0] - resized.width) // 2, (IMAGE_SIZE[1] - resized.height) // 2)
|
||||
output.alpha_composite(resized, origin)
|
||||
|
||||
def projected(source):
|
||||
point = (source[[0, 2]] * np.array([1, -1]) - minimum) * SOURCE_PIXELS_PER_UNIT + shift
|
||||
return np.array(origin) + (point - np.array([left, top])) * scale
|
||||
|
||||
return output, projected, confidence
|
||||
|
||||
|
||||
def navigation_plan(vertices, faces):
|
||||
horizontal = vertices[:, [0, 2]] * np.array([1, -1])
|
||||
minimum = horizontal.min(axis=0)
|
||||
maximum = horizontal.max(axis=0)
|
||||
span = np.maximum(maximum - minimum, 1)
|
||||
scale = min((IMAGE_SIZE[0] - 80) / span[0], (IMAGE_SIZE[1] - 80) / span[1])
|
||||
origin = np.array([(IMAGE_SIZE[0] - span[0] * scale) / 2,
|
||||
(IMAGE_SIZE[1] - span[1] * scale) / 2])
|
||||
points = origin + (horizontal - minimum) * scale
|
||||
image = Image.new("RGBA", IMAGE_SIZE, (10, 14, 17, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
for face in faces:
|
||||
draw.polygon([tuple(points[index]) for index in face], fill=(43, 68, 76, 255))
|
||||
|
||||
def projected(runtime):
|
||||
return origin + (runtime[[0, 2]] * np.array([1, -1]) - minimum) * scale
|
||||
|
||||
return image, projected
|
||||
|
||||
|
||||
def affine(projected, placement=None):
|
||||
def runtime_to_source(runtime):
|
||||
return source_points(np.array([runtime], dtype=np.float64), placement)[0]
|
||||
|
||||
base = projected(runtime_to_source([0, 0, 0])) if placement else projected(np.array([0, 0, 0]))
|
||||
axes = []
|
||||
for axis in range(3):
|
||||
unit = np.zeros(3)
|
||||
unit[axis] = 1
|
||||
point = projected(runtime_to_source(unit)) if placement else projected(unit)
|
||||
axes.append(point - base)
|
||||
return [round(float(value), 8) for row in range(2) for value in
|
||||
(axes[0][row], axes[1][row], axes[2][row], base[row])]
|
||||
|
||||
|
||||
def main():
|
||||
maps = {}
|
||||
summary = []
|
||||
for recipe_file in sorted(RECIPES.glob("*.json")):
|
||||
if recipe_file.name.endswith("-population.json") or recipe_file.stem == "runewaker-player-avatars":
|
||||
continue
|
||||
recipe = read_json(recipe_file)
|
||||
slug = recipe["slug"]
|
||||
vertices, faces = navigation_mesh(slug)
|
||||
placement = placement_for(slug)
|
||||
source_vertices = source_points(vertices, placement)
|
||||
map_name = ALIASES.get(slug, Path(recipe["source"]["wdb"]).stem)
|
||||
tiles = client_tiles(map_name)
|
||||
calibrated = calibrate(tiles[0], source_vertices, faces) if tiles else None
|
||||
if calibrated and calibrated[2] >= 0.65:
|
||||
image, projected, confidence = original_map(tiles[0], source_vertices, faces, calibrated)
|
||||
matrix = affine(projected, placement)
|
||||
kind = "client"
|
||||
source_hash = hashlib.sha256(b"".join(file.read_bytes() for file in tiles[1])).hexdigest()
|
||||
else:
|
||||
image, projected = navigation_plan(vertices, faces)
|
||||
matrix = affine(projected)
|
||||
kind = "navigation"
|
||||
confidence = calibrated[2] if calibrated else 0
|
||||
source_hash = hashlib.sha256((ROOT / f"src/assets/game/dungeons/{slug}/{slug}-navigation.glb").read_bytes()).hexdigest()
|
||||
destination = OUTPUT / slug / "maps" / "runewaker-map.png"
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.save(destination, optimize=True)
|
||||
metadata = read_json(ROOT / f"src/assets/game/dungeons/{slug}/import-metadata.json")
|
||||
maps[slug] = {
|
||||
"mapId": read_json(next((ROOT / "scripts/runewaker-pipeline/snapshots").glob(f"{slug}-zone-*.json")))["zoneId"],
|
||||
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
|
||||
"visualChecksum": runtime_visual_checksum(slug),
|
||||
"kind": kind,
|
||||
"image": f"/assets/game/dungeons/{slug}/maps/runewaker-map.png",
|
||||
"checksum": hashlib.sha256(destination.read_bytes()).hexdigest(),
|
||||
"sourceHash": source_hash,
|
||||
"runtimeToMap": matrix,
|
||||
"width": IMAGE_SIZE[0],
|
||||
"height": IMAGE_SIZE[1],
|
||||
}
|
||||
summary.append((slug, kind, round(confidence, 3), metadata["status"]))
|
||||
CATALOG.write_text(json.dumps({"schemaVersion": 1, "dungeons": maps}, indent=2) + "\n", encoding="utf-8")
|
||||
for entry in summary:
|
||||
print(*entry)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user