P1. The project reads only `export/` -- the manifest, a screen's JSON and its PNGs -- and draws every element at `rest`, in the export's own `paint_order`. Three choices worth the words: * `ExportTree` addresses screens by manifest NAME, never by path, and checks `format` on the manifest and on each screen before drawing. Textures are decoded from bytes at runtime rather than Godot-imported: `export/` is gitignored and regenerated wholesale, and a `.import` per sprite would be derived state next to derived state, invalidated on every re-export. * One CanvasItem draws the whole screen. `paint_order` is already back-to-front, so honouring it is a loop; spreading it across sixteen nodes' z-indices would hide the one unresolved thing about that order -- the ties -- behind Godot's sibling rules. * The screen renders into a SubViewport sized to the export's `design` rect. Capturing the window instead gave 1280x720 of screen minus a window manager's title bar: 1235x695. A gate that rescales that to compare against a 1280x720 composite is measuring the compositor. Nearest-neighbour filtering, because the export is a 1:1 copy of the disc's texels, elements draw at up to 500 %, and it is what `ui_layout::blit` does -- so a filter difference cannot masquerade as a placement difference in the diff. No keyframe interpolation and no focus state: both depend on constants that are MEASURED rather than decoded (HANDOFF Q1, Q5), and a pixel-diff gate must not have one of those inside it. P2 and P5.
96 lines
3.0 KiB
GDScript
96 lines
3.0 KiB
GDScript
# Locating and reading the open export tree.
|
|
#
|
|
# The Godot project NEVER reads a disc format (docs/MISSION.md §2). Everything
|
|
# it draws comes from `export/`, which is derived, gitignored and regenerated
|
|
# wholesale by `crates/sylpheed-export`. This class is the only place that knows
|
|
# where that tree is on disk.
|
|
class_name ExportTree
|
|
extends RefCounted
|
|
|
|
const FORMAT_SCREEN := "sylpheed.screen/2"
|
|
const FORMAT_MANIFEST := "sylpheed.manifest/1"
|
|
|
|
var root: String = ""
|
|
var error: String = ""
|
|
|
|
|
|
# `SYLPHEED_EXPORT` wins, so a modder can point the game at their own tree
|
|
# without touching the project. Otherwise `<project>/../export`, which is the
|
|
# layout this repository has.
|
|
static func locate() -> ExportTree:
|
|
var t := ExportTree.new()
|
|
var env := OS.get_environment("SYLPHEED_EXPORT")
|
|
var candidate := env
|
|
if candidate == "":
|
|
candidate = ProjectSettings.globalize_path("res://").path_join("../export").simplify_path()
|
|
if not FileAccess.file_exists(candidate.path_join("manifest.json")):
|
|
t.error = "no manifest.json under %s -- run `sylpheed-export` first" % candidate
|
|
return t
|
|
t.root = candidate
|
|
return t
|
|
|
|
|
|
func read_json(rel: String) -> Variant:
|
|
var path := root.path_join(rel)
|
|
var text := FileAccess.get_file_as_string(path)
|
|
if text == "":
|
|
error = "cannot read %s" % path
|
|
return null
|
|
var parsed: Variant = JSON.parse_string(text)
|
|
if parsed == null:
|
|
error = "%s is not JSON" % path
|
|
return null
|
|
return parsed
|
|
|
|
|
|
func manifest() -> Dictionary:
|
|
var m: Variant = read_json("manifest.json")
|
|
if m == null:
|
|
return {}
|
|
if m.get("format") != FORMAT_MANIFEST:
|
|
error = "manifest.json is %s, this build reads %s" % [m.get("format"), FORMAT_MANIFEST]
|
|
return {}
|
|
return m
|
|
|
|
|
|
# Screens are addressed by their manifest name, not by a path, so the caller
|
|
# never has to know the archive's subdirectory.
|
|
func screen(name: String) -> Dictionary:
|
|
var m := manifest()
|
|
if m.is_empty():
|
|
return {}
|
|
for entry: Dictionary in m.get("screens", []):
|
|
if entry.get("name") == name:
|
|
var s: Variant = read_json(entry["file"])
|
|
if s == null:
|
|
return {}
|
|
if s.get("format") != FORMAT_SCREEN:
|
|
error = "%s is %s, this build reads %s" % [name, s.get("format"), FORMAT_SCREEN]
|
|
return {}
|
|
return s
|
|
error = "no screen named %s in manifest.json" % name
|
|
return {}
|
|
|
|
|
|
func screen_names() -> PackedStringArray:
|
|
var names := PackedStringArray()
|
|
for entry: Dictionary in manifest().get("screens", []):
|
|
names.append(entry["name"])
|
|
return names
|
|
|
|
|
|
# Textures live outside res://, so they are read as bytes and decoded at
|
|
# runtime rather than imported. Nearest-neighbour: the export is a 1:1 copy of
|
|
# the disc's own texels and several elements are drawn at 200 %, where a
|
|
# bilinear filter would invent detail the disc does not have.
|
|
func texture(rel: String) -> Texture2D:
|
|
var bytes := FileAccess.get_file_as_bytes(root.path_join(rel))
|
|
if bytes.is_empty():
|
|
error = "cannot read sprite %s" % rel
|
|
return null
|
|
var img := Image.new()
|
|
if img.load_png_from_buffer(bytes) != OK:
|
|
error = "%s is not a PNG" % rel
|
|
return null
|
|
return ImageTexture.create_from_image(img)
|