port: play the keyframe timeline, with the time unit authored in one place
P2. A keyframe is the start of a linear ramp toward the next; `ScreenView` walks them at `time_units` and `boot.gd` advances that in real time, or freezes it with `--time=<seconds>`. `authored/timing.json` holds the ONE constant this needs. HANDOFF Q1 is answered -- linear, 2 units per rendered frame, 1 unit = 1/60 s -- but that conversion was MEASURED off the running game, not read from a file, so it is authored rather than exported and it says so at length. Expressed as units-per-second, because 60 is exact and 0.01666... is a decimal a reader has to recognise. The timeline stops at the last TIMED keyframe and never plays the exit. Every group's final keyframe carries no `t` -- across this export it is a fade-out for 116 of 134 elements, a scale-and-slide exit for 12, and identical for 6 -- so playing into it would mean inventing how long the ramp takes. That duration is the screen transition, it is measured at ~0.4 s, and it is P3's to author with its own evidence. `exit_ramp_seconds` is therefore null on purpose, not missing. `--pose=rest` keeps the P1 behaviour available: since the port's default is now the timeline and the two DISAGREE, renderer-vs-renderer diffing has to be able to ask for the same assumption the reference renderer makes. The interpolation is checked by where it lands: on 8 of the 12 screens the settled timeline is byte-identical to the rest render.
This commit is contained in:
39
authored/timing.json
Normal file
39
authored/timing.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"format": "sylpheed.timing/1",
|
||||
|
||||
"keyframe_units_per_second": 60,
|
||||
"why": [
|
||||
"HANDOFF Q1. The disc says a keyframe is at `t=30`; it does not say what a",
|
||||
"`t` is. The unit was MEASURED off the running game, not decoded: a declared",
|
||||
"15-unit fade lands on round(255*k/15) for all seven of its samples with k",
|
||||
"stepping 2,4,6,8,10,12,14 on seven consecutive submitted frames -- so 2",
|
||||
"units per rendered frame -- and the idle title presents at 28.3-28.8 fps,",
|
||||
"a 30 Hz game, giving 60 units per second. A second line agrees: the",
|
||||
"transition quad is declared black for 12 units, and a capture measured the",
|
||||
"pure-black plateau at 0.17-0.23 s, where 12/60 = 0.20 s.",
|
||||
"",
|
||||
"Expressed as units-per-second rather than seconds-per-unit so the value is",
|
||||
"exact rather than a repeating decimal a reader has to recognise.",
|
||||
"",
|
||||
"DELETE THIS FILE when a field on the disc is found that states the unit.",
|
||||
"Nothing here is on the disc."
|
||||
],
|
||||
"kind": "measured",
|
||||
"source": "/reborn docs/port/HANDOFF.md Q1, docs/re/ui-keyframe-time-unit.md",
|
||||
|
||||
"ramp": "linear",
|
||||
"ramp_why": [
|
||||
"Also HANDOFF Q1, and part of the same measurement: the fade lands on the",
|
||||
"linear value at every one of the seven sampled frames, so there is no ease."
|
||||
],
|
||||
|
||||
"exit_ramp_seconds": null,
|
||||
"exit_ramp_why": [
|
||||
"NOT SET, deliberately. The last keyframe of every group carries no time --",
|
||||
"the disc has no time slot there -- so the duration of the ramp INTO the",
|
||||
"exit pose is unknown. HANDOFF Q7 measured the screen fade-out at ~0.4 s,",
|
||||
"but that is the transition, which is P3's to author with its own evidence.",
|
||||
"P2 plays the timed keyframes and holds; it never plays the exit ramp,",
|
||||
"because it would have to invent how long it takes."
|
||||
]
|
||||
}
|
||||
@@ -6,6 +6,13 @@
|
||||
#
|
||||
# godot --path port -- --screen=main_menu
|
||||
# godot --path port -- --screen=main_menu --capture=/tmp/godot.png
|
||||
# godot --path port -- --screen=main_menu --time=0.5 --capture=/tmp/at-half.png
|
||||
# godot --path port -- --screen=main_menu --pose=rest --capture=/tmp/rest.png
|
||||
#
|
||||
# `--time` is in SECONDS and freezes the timeline there; without it the screen
|
||||
# animates in real time from t=0. `--pose=rest` draws the export's declared
|
||||
# resting pose instead of the timeline -- what the reference renderer draws, so
|
||||
# that a renderer-vs-renderer diff compares like with like.
|
||||
#
|
||||
# The screen is drawn into a SubViewport sized to the export's own `design`
|
||||
# rectangle and shown through a container that scales it to the window. That is
|
||||
@@ -56,6 +63,17 @@ func _ready() -> void:
|
||||
# filter difference cannot masquerade as a placement difference in the diff.
|
||||
view.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
||||
view.focused_id = args.get("focus", "")
|
||||
if args.get("pose", "") == "rest":
|
||||
view.pose_mode = ScreenView.Pose.REST
|
||||
|
||||
# The keyframe unit is MEASURED, not on the disc, so it is authored and read
|
||||
# in exactly one place -- here.
|
||||
var timing: Variant = export_tree.authored("timing.json")
|
||||
if timing == null:
|
||||
push_error(export_tree.error)
|
||||
get_tree().quit(2)
|
||||
return
|
||||
view.units_per_second = float(timing["keyframe_units_per_second"])
|
||||
viewport.add_child(view)
|
||||
|
||||
if not view.load_screen(export_tree, name):
|
||||
@@ -63,20 +81,39 @@ func _ready() -> void:
|
||||
get_tree().quit(2)
|
||||
return
|
||||
|
||||
print("screen %s: %d elements, %d in paint order, design %dx%d" % [
|
||||
var settle := view.settle_time()
|
||||
print("screen %s: %d elements, %d in paint order, design %dx%d, settles at t=%d (%.3f s)" % [
|
||||
name, view.screen["elements"].size(), view.screen["paint_order"].size(),
|
||||
design[0], design[1]])
|
||||
design[0], design[1], settle, settle / view.units_per_second])
|
||||
|
||||
if args.has("time"):
|
||||
_frozen = true
|
||||
view.time_units = float(args["time"]) * view.units_per_second
|
||||
view.queue_redraw()
|
||||
|
||||
if args.has("capture"):
|
||||
await _capture(args["capture"])
|
||||
get_tree().quit(0)
|
||||
|
||||
|
||||
var _frozen := false
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _frozen or view == null:
|
||||
return
|
||||
view.time_units += delta * view.units_per_second
|
||||
view.queue_redraw()
|
||||
|
||||
|
||||
func _capture(path: String) -> void:
|
||||
# Two frames: the first is the one this callback is still inside of.
|
||||
await RenderingServer.frame_post_draw
|
||||
await RenderingServer.frame_post_draw
|
||||
var img := viewport.get_texture().get_image()
|
||||
print("t = %.2f units (%.3f s), pose = %s" % [
|
||||
view.time_units, view.time_units / view.units_per_second,
|
||||
"rest" if view.pose_mode == ScreenView.Pose.REST else "timeline"])
|
||||
print("drew %d: %s" % [view.drawn.size(), ", ".join(view.drawn)])
|
||||
if not view.skipped.is_empty():
|
||||
print("not drawn %d: %s" % [view.skipped.size(), ", ".join(view.skipped)])
|
||||
|
||||
@@ -30,6 +30,17 @@ static func locate() -> ExportTree:
|
||||
return t
|
||||
|
||||
|
||||
# `authored/` sits beside `export/`, never inside it: it is hand-written and
|
||||
# committed, and a re-export must not be able to touch it.
|
||||
func authored(name: String) -> Variant:
|
||||
var path := root.path_join("../authored").simplify_path().path_join(name)
|
||||
var text := FileAccess.get_file_as_string(path)
|
||||
if text == "":
|
||||
error = "cannot read %s" % path
|
||||
return null
|
||||
return JSON.parse_string(text)
|
||||
|
||||
|
||||
func read_json(rel: String) -> Variant:
|
||||
var path := root.path_join(rel)
|
||||
var text := FileAccess.get_file_as_string(path)
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
# Draws one exported screen at its resting pose.
|
||||
# Draws one exported screen, either at a moment on its timeline or at the
|
||||
# `rest` pose the export declares.
|
||||
#
|
||||
# P1 is static: every element is drawn at `rest`, the pose the screen holds
|
||||
# once it has finished arriving (docs/FORMAT.md). Keyframe animation is P2 and
|
||||
# is deliberately not here -- the keyframe time unit is measured rather than
|
||||
# decoded, and this milestone must not depend on it.
|
||||
# TIMELINE is the real behaviour and the default. A keyframe is the start of a
|
||||
# LINEAR ramp toward the next, and the unit of `t` comes from
|
||||
# `authored/timing.json` -- it is measured, not on the disc, which is why it is
|
||||
# authored and applied in exactly one place.
|
||||
#
|
||||
# REST reproduces what the export's `rest` field says, which is what
|
||||
# `sylpheed-cli screen render` draws. It is kept so `tools/verify-screen` can
|
||||
# hold both renderers to the same assumption. The two modes DISAGREE on six
|
||||
# elements in this export, and the running game sides with the timeline -- see
|
||||
# `docs/DECISIONS.md`.
|
||||
#
|
||||
# One CanvasItem draws the whole screen in `_draw`, rather than a node per
|
||||
# element. The export's `paint_order` is already back-to-front, so honouring it
|
||||
@@ -19,6 +26,17 @@ extends Node2D
|
||||
## with no template to duplicate, and a blanket skip would erase them.
|
||||
const KIND_TEMPLATE_INSTANCE := 0x4
|
||||
|
||||
enum Pose { TIMELINE, REST }
|
||||
|
||||
## Which pose to draw. TIMELINE walks the keyframes at `time_units`; REST draws
|
||||
## the export's declared `rest` and is there for renderer-vs-renderer diffing.
|
||||
var pose_mode: Pose = Pose.TIMELINE
|
||||
|
||||
## Position on the timeline, in the disc's own keyframe units. `t` is left raw
|
||||
## everywhere; seconds appear only where `units_per_second` is applied.
|
||||
var time_units: float = 0.0
|
||||
var units_per_second: float = 60.0
|
||||
|
||||
var tree: ExportTree = null
|
||||
var screen: Dictionary = {}
|
||||
var textures: Dictionary = {}
|
||||
@@ -97,6 +115,74 @@ static func _vec(a: Array) -> Vector2:
|
||||
return Vector2(float(a[0]), float(a[1]))
|
||||
|
||||
|
||||
## The pose of one element at `time_units`.
|
||||
##
|
||||
## The timed keyframes are the whole timeline. Before the first, the element
|
||||
## holds its first pose (the pre-roll a staggered menu needs -- the five buttons
|
||||
## start at t=28,30,32,34,36). After the last TIMED keyframe it holds that pose.
|
||||
##
|
||||
## It never plays into the final, untimed keyframe. That frame is the screen's
|
||||
## EXIT pose, and the disc gives no time slot for the ramp into it, so playing
|
||||
## it would mean inventing a duration. The exit is the transition, and it is
|
||||
## P3's, with its own measured evidence. See `authored/timing.json`.
|
||||
func pose_at(element: Dictionary, t: float) -> Dictionary:
|
||||
var frames: Array = element.get("keyframes", [])
|
||||
var timed: Array = []
|
||||
for k: Dictionary in frames:
|
||||
if k.has("t"):
|
||||
timed.append(k)
|
||||
if timed.is_empty():
|
||||
# No timed frame at all: the group is a single static pose.
|
||||
return frames[0] if not frames.is_empty() else element.get("rest", {})
|
||||
if t <= float(timed[0]["t"]):
|
||||
return timed[0]
|
||||
for i in range(timed.size() - 1):
|
||||
var a: Dictionary = timed[i]
|
||||
var b: Dictionary = timed[i + 1]
|
||||
var t0 := float(a["t"])
|
||||
var t1 := float(b["t"])
|
||||
if t < t1:
|
||||
# A keyframe is the start of a ramp toward the next, and the ramp is
|
||||
# linear -- measured, `authored/timing.json`.
|
||||
return _lerp_pose(a, b, 0.0 if t1 <= t0 else (t - t0) / (t1 - t0))
|
||||
return timed[timed.size() - 1]
|
||||
|
||||
|
||||
# Channels are integers on the disc. The running game's own fade lands on
|
||||
# `round(255*k/15)`, so rounding -- not truncation -- is what was measured.
|
||||
static func _lerp_pose(a: Dictionary, b: Dictionary, f: float) -> Dictionary:
|
||||
return {
|
||||
"pos": [_ilerp(a["pos"][0], b["pos"][0], f), _ilerp(a["pos"][1], b["pos"][1], f)],
|
||||
"scale": [_ilerp(a["scale"][0], b["scale"][0], f), _ilerp(a["scale"][1], b["scale"][1], f)],
|
||||
"tint_rgba": _hex_lerp(a["tint_rgba"], b["tint_rgba"], f),
|
||||
"fade_argb": _hex_lerp(a["fade_argb"], b["fade_argb"], f),
|
||||
}
|
||||
|
||||
|
||||
static func _ilerp(a: float, b: float, f: float) -> int:
|
||||
return int(round(a + (b - a) * f))
|
||||
|
||||
|
||||
# Byte-wise, so it works for both orders without knowing which one it has.
|
||||
static func _hex_lerp(a: String, b: String, f: float) -> String:
|
||||
var x := a.hex_to_int()
|
||||
var y := b.hex_to_int()
|
||||
var out := 0
|
||||
for shift in [24, 16, 8, 0]:
|
||||
out |= (_ilerp((x >> shift) & 0xff, (y >> shift) & 0xff, f) & 0xff) << shift
|
||||
return "0x%08x" % out
|
||||
|
||||
|
||||
## The last moment anything on this screen is still moving, in keyframe units.
|
||||
func settle_time() -> float:
|
||||
var last := 0.0
|
||||
for element: Dictionary in screen.get("elements", []):
|
||||
for k: Dictionary in element.get("keyframes", []):
|
||||
if k.has("t"):
|
||||
last = maxf(last, float(k["t"]))
|
||||
return last
|
||||
|
||||
|
||||
# An element is a ghost only when another element on the same screen carries the
|
||||
# same id *without* the template bit -- the template it is a repeat of.
|
||||
func _template_instance_ids() -> Dictionary:
|
||||
@@ -125,7 +211,8 @@ func _draw() -> void:
|
||||
if ghosts.has(index):
|
||||
skipped.append("%s (template instance)" % id)
|
||||
continue
|
||||
var pose: Dictionary = element.get("rest", {})
|
||||
var pose: Dictionary = element.get("rest", {}) if pose_mode == Pose.REST \
|
||||
else pose_at(element, time_units)
|
||||
var colour := modulate_of(pose)
|
||||
if colour.a <= 0.0:
|
||||
skipped.append("%s (transparent at rest)" % id)
|
||||
|
||||
Reference in New Issue
Block a user