Files
ReSkate/contrib/plugins/animator-studio/plugin/main.lua
T
AyeZeeBB 2b0b744ec5 Animator Studio: not opened during a bail, and the walk back from the character room always ends
- The studio waits until a bail is over (its state, or 4 s after the bail event): held still mid-bail,
  the game took the hold away and the skater went on bailing behind the studio.
- The character room's walk back gives up after 8 s: with no skater ready (a respawn) it could not
  step, and the studio stayed on its loading screen.
2026-10-20 18:46:02 -05:00

2634 lines
125 KiB
Lua

-- Animator Studio, by 22sh, Fourth Studio and Norms (pull request 297), as a plugin. Record a trick,
-- change the body and the board on a timeline, save it, and from then on doing that trick in game
-- plays your version (reskate.animation.replace); every other trick stays the game's own.
--
-- The screen is the PR's (animation_studio_overlay.cpp), drawn here with the overlay's shapes: the
-- skater in the middle on the studio's own camera, skate.'s Trick List menu on the left with its
-- tabs, the white card under it, a timeline of the body's joints along the bottom, the prompts and
-- KEY / SAVE in the lower right, the unsaved-changes card, the recording strip and the loading screen.
-- It works from the keyboard and mouse or from a controller, the prompts following whichever was
-- used last. What the studio does (the PR's anim_studio.cpp):
-- - A recording is the skater as the game draws it (reskate.animation.record), cut to the trick:
-- from a little before its takeoff to a little after its landing (trim). It records again until a
-- trick is landed in it, or Esc.
-- - Keys turn a joint in its own frame and move it in its parent's (the hips, the board), on top of
-- the recording. They are baked into a clip (reskate.animation.edit) shown on the skater, held at
-- the playhead (play paused, seek); a change shows at once through reskate.skater.pose until the
-- next bake has it.
-- - Saved animations are the recording as a clip file and the keys beside it in the data folder
-- (anims/<name>.fas and .json). With Custom tricks in game on, each plays on its trick and pop.
-- - The game's own tricks, its clips read out of its data (reskate.animation.game_clips, "game:"
-- clips), are listed in TRICKS to start from.
-- - The studio opens in its room, the character room of the game menu's Skaters page
-- (reskate.room walks the game's menu there and shows the skater in it), or where the skater is
-- with the Studio room setting off or when the walk fails.
-- - The card's NAME, TRICK and numbers are typed into in place (reskate.input.text).
local title = "Animator Studio"
local fps = 30
local joints = reskate.animation.joints() -- {joint, label, moves}, the board first
local joint_row = {}
for i, j in joints do joint_row[tostring(j.joint)] = i end
local function label_of(joint)
local i = joint_row[tostring(joint)]
return if i then joints[i].label else "Joint " .. tostring(joint)
end
local function moves(joint)
local i = joint_row[tostring(joint)]
return i ~= nil and joints[i].moves
end
local function upper(s) return string.upper(s) end
local function clamp(v, a, b) return math.max(a, math.min(b, v)) end
local function same(a, b) return tostring(a) == tostring(b) end
-- ---- colours: skate.'s look --------------------------------------------------------------------------
local function rgba(r, g, b, a) return r * 16777216 + g * 65536 + b * 256 + (a or 255) end
local white, black = rgba(245, 245, 245), rgba(0, 0, 0)
local soft, faint = rgba(168, 170, 176), rgba(112, 114, 120)
local blue, blue_dim, blue_light = rgba(1, 131, 255), rgba(1, 131, 255, 110), rgba(90, 180, 255)
local danger = rgba(255, 92, 92)
-- The colour with its alpha scaled by `a` (0 to 1).
local function with_alpha(c, a) return c - (c % 256) + math.floor(clamp(a, 0, 1) * (c % 256)) end
-- ---- quaternions {x, y, z, w}, as anim_core's ---------------------------------------------------------
local function qmul(a, b)
return {a[4] * b[1] + a[1] * b[4] + a[2] * b[3] - a[3] * b[2], a[4] * b[2] - a[1] * b[3] + a[2] * b[4] + a[3] * b[1],
a[4] * b[3] + a[1] * b[2] - a[2] * b[1] + a[3] * b[4], a[4] * b[4] - a[1] * b[1] - a[2] * b[2] - a[3] * b[3]}
end
local function qconj(q) return {-q[1], -q[2], -q[3], q[4]} end
local function qnorm(q)
local l = math.sqrt(q[1] * q[1] + q[2] * q[2] + q[3] * q[3] + q[4] * q[4])
if not (l > 1e-8) then return {0, 0, 0, 1} end
return {q[1] / l, q[2] / l, q[3] / l, q[4] / l}
end
local function qslerp(a, b, t)
local c = a[1] * b[1] + a[2] * b[2] + a[3] * b[3] + a[4] * b[4]
if c < 0 then
a = {-a[1], -a[2], -a[3], -a[4]}
c = -c
end
if c > 0.9995 then
return qnorm({a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t, a[4] + (b[4] - a[4]) * t})
end
local angle = math.acos(clamp(c, -1, 1))
local s = math.sin(angle)
local wa, wb = math.sin((1 - t) * angle) / s, math.sin(t * angle) / s
return {a[1] * wa + b[1] * wb, a[2] * wa + b[2] * wb, a[3] * wa + b[3] * wb, a[4] * wa + b[4] * wb}
end
local function axis_angle(axis, radians)
local l = math.sqrt(axis[1] * axis[1] + axis[2] * axis[2] + axis[3] * axis[3])
if not (l > 1e-8) then return {0, 0, 0, 1} end
local s = math.sin(radians * 0.5) / l
return {axis[1] * s, axis[2] * s, axis[3] * s, math.cos(radians * 0.5)}
end
local function rotate(q, v)
local ux, uy, uz, s = q[1], q[2], q[3], q[4]
local tx, ty, tz = 2 * (uy * v[3] - uz * v[2]), 2 * (uz * v[1] - ux * v[3]), 2 * (ux * v[2] - uy * v[1])
return {v[1] + s * tx + (uy * tz - uz * ty), v[2] + s * ty + (uz * tx - ux * tz), v[3] + s * tz + (ux * ty - uy * tx)}
end
local function from_euler(d)
local k = math.pi / 180
return qnorm(qmul(qmul(axis_angle({1, 0, 0}, d[1] * k), axis_angle({0, 1, 0}, d[2] * k)), axis_angle({0, 0, 1}, d[3] * k)))
end
local function to_euler(raw)
local q = qnorm(raw)
local x, y, z, w = q[1], q[2], q[3], q[4]
local m00, m01, m02 = 1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)
local m12, m22 = 2 * (y * z - x * w), 1 - 2 * (x * x + y * y)
local k = 180 / math.pi
local b = math.asin(clamp(m02, -1, 1))
if math.abs(m02) > 0.9999 then
local m10, m11 = 2 * (x * y + z * w), 1 - 2 * (x * x + z * z)
return {math.atan2(m10, m11) * k, b * k, 0}
end
return {math.atan2(-m12, m22) * k, b * k, math.atan2(-m01, m00) * k}
end
local function v3(v) return {v.x, v.y, v.z} end
local function vec(t) return vector.create(t[1], t[2], t[3]) end
-- ---- the animation being edited (the PR's anim_studio) --------------------------------------------------
local anim = {
name = "", trick = "", pop = "", speed = 1, loop = true,
source = nil, -- the clip the keys go on
-- By tostring(joint): {joint, keys = {{t, r = {x, y, z, w}, o = {x, y, z}, e = "smooth"}}, hidden, locked}.
tracks = {},
}
local info = nil -- reskate.animation.info(anim.source)
local settings = {in_game = true, room = true}
local studio = {open = false, time = 0, playing = false, dirty = false, status = "", failure = "", confirm_close = false}
local undo_stack, redo_stack = {}, {}
local orbit = {yaw = 0.6, pitch = 0.18, distance = 3.4, pan = {0, 0, 0}, focus = nil}
local anchor = nil -- the skater's middle, where the studio opened (before his pose is read)
local in_room, room_walk = false, false -- the studio shows in the character room; the walk there goes on
local face_skater = false -- the orbit turns to face the skater once his placement is read
local camera = nil -- {position, forward, right, up, fov} as last set
local recording = nil -- {trick, air, status} while a recording is going on
local stale_recording = false -- a recording given up on: its clip is let go when it comes
local loading = nil -- {what = "opening" | "leaving", since, until_, close}
local clipboard = nil -- copy_pose
local function now() return reskate.time.now() / 1000 end
local function safe(name)
local s = string.gsub(name, "[^%w%-_ ]", "")
s = string.gsub(s, "^%s+", "")
s = string.gsub(s, "%s+$", "")
return string.sub(s, 1, 40)
end
local function track_of(joint, make)
local key = tostring(joint)
if not anim.tracks[key] and make then anim.tracks[key] = {joint = joint, keys = {}, hidden = false, locked = false} end
return anim.tracks[key]
end
-- A track's edit at `t`, as anim_core's evaluate: before the first key and after the last, the nearest.
local function hermite(p0, p1, p2, p3, u)
local u2, u3 = u * u, u * u * u
local h00, h10, h01, h11 = 2 * u3 - 3 * u2 + 1, u3 - 2 * u2 + u, -2 * u3 + 3 * u2, u3 - u2
local out = {}
for i = 1, 3 do
local m1, m2 = (p2[i] - p0[i]) * 0.5, (p3[i] - p1[i]) * 0.5
out[i] = h00 * p1[i] + h10 * m1 + h01 * p2[i] + h11 * m2
end
return out
end
local function evaluate(track, t)
if not track or #track.keys == 0 then return {0, 0, 0, 1}, {0, 0, 0} end
local keys = track.keys
if t <= keys[1].t then return keys[1].r, keys[1].o end
if t >= keys[#keys].t then return keys[#keys].r, keys[#keys].o end
local i = 1
while i + 1 <= #keys and keys[i + 1].t <= t do i += 1 end
local a, b = keys[i], keys[i + 1]
local span = b.t - a.t
local u = if span > 1e-6 then clamp((t - a.t) / span, 0, 1) else 1
if a.e == "step" then return a.r, a.o end
if a.e == "linear" then
return qslerp(a.r, b.r, u), {a.o[1] + (b.o[1] - a.o[1]) * u, a.o[2] + (b.o[2] - a.o[2]) * u, a.o[3] + (b.o[3] - a.o[3]) * u}
end
local eased = u * u * (3 - 2 * u)
local before = if i > 1 then keys[i - 1].o else a.o
local after = if i + 2 <= #keys then keys[i + 2].o else b.o
return qslerp(a.r, b.r, eased), hermite(before, a.o, b.o, after, u)
end
local function offset_of(joint) return evaluate(track_of(joint, false), studio.time) end
local function key_here(joint)
local track = track_of(joint, false)
if not track then return nil end
for i, k in track.keys do
if math.abs(k.t - studio.time) < 0.5 / fps then return k, i end
end
return nil
end
-- ---- changes, undo, the live preview -------------------------------------------------------------------
local pending = {} -- joints changed since the last bake
local baked = {} -- the tracks as last baked
local baked_clip = nil -- "edit:studio" once there is one
local preview_due, last_bake = false, -1e9
local posing = false
local last_change = 0
local function copy_tracks(tracks) return reskate.json.decode(reskate.json.encode(tracks)) end
local function begin_change()
table.insert(undo_stack, reskate.json.encode(anim.tracks))
if #undo_stack > 50 then table.remove(undo_stack, 1) end
redo_stack = {}
end
local function changed(joint)
studio.dirty = true
preview_due = true
if joint ~= nil then pending[tostring(joint)] = joint end
end
local function changed_all()
studio.dirty = true
preview_due = true
for key, track in anim.tracks do pending[key] = track.joint end
for key, track in baked do pending[key] = track.joint end
end
local function set_offset(joint, r, o)
local track = track_of(joint, true)
if track.locked then return end
local t = math.floor(studio.time * fps + 0.5) / fps
local existing = nil
for _, k in track.keys do
if math.abs(k.t - t) < 0.5 / fps then existing = k end
end
if existing then
existing.r, existing.o = qnorm(r), o
else
local at = #track.keys + 1
for i, e in track.keys do
if e.t > t then at = i break end
end
table.insert(track.keys, at, {t = t, r = qnorm(r), o = o, e = "smooth"})
end
changed(joint)
end
local function delete_key_at(joint, t)
local track = track_of(joint, false)
if not track or track.locked then return end
for i, k in track.keys do
if math.abs(k.t - t) < 1e-4 then
table.remove(track.keys, i)
changed(joint)
return
end
end
end
local function move_key(joint, from, to)
local track = track_of(joint, false)
if not track or track.locked then return end
to = clamp(to, 0, if info then info.duration else to)
for i, k in track.keys do
if math.abs(k.t - from) < 1e-4 then
table.remove(track.keys, i)
for j = #track.keys, 1, -1 do
if math.abs(track.keys[j].t - to) < 0.5 / fps then table.remove(track.keys, j) end
end
k.t = to
local at = #track.keys + 1
for n, e in track.keys do
if e.t > to then at = n break end
end
table.insert(track.keys, at, k)
changed(joint)
return
end
end
end
local function undo()
if #undo_stack == 0 then return end
table.insert(redo_stack, reskate.json.encode(anim.tracks))
anim.tracks = reskate.json.decode(table.remove(undo_stack))
changed_all()
end
local function redo()
if #redo_stack == 0 then return end
table.insert(undo_stack, reskate.json.encode(anim.tracks))
anim.tracks = reskate.json.decode(table.remove(redo_stack))
changed_all()
end
local function edit_tracks(tracks)
local list = {}
for _, track in tracks do
if #track.keys > 0 and not track.hidden then
local keys = {}
for _, k in track.keys do table.insert(keys, {time = k.t, rotation = k.r, offset = vec(k.o), ease = k.e}) end
table.insert(list, {joint = track.joint, keys = keys})
end
end
return list
end
local function set_playing(on)
if on == studio.playing then return end
studio.playing = on
reskate.animation.pause(not on)
end
local function bake()
if not anim.source then return end
local ok, clip = pcall(reskate.animation.edit, anim.source, edit_tracks(anim.tracks), {name = "studio"})
if not ok then
studio.failure = "The keys could not be put on the clip: " .. tostring(clip)
preview_due = false
return
end
baked_clip = clip
local played, why = reskate.animation.play(clip, {paused = not studio.playing, at = clamp(studio.time, 0, info.duration),
loop = anim.loop, blend = 0, speed = anim.speed, floor = true})
if played then
preview_due = false
last_bake = reskate.time.now()
baked = copy_tracks(anim.tracks)
pending = {}
studio.failure = ""
elseif why ~= "asked more than 4 times in a second" then
studio.failure = "The studio cannot show it: " .. why
preview_due = false
end
end
-- What changed since the last bake, on the skater now: each joint turned and moved by the difference.
local function show_pending()
local offsets, any = {}, false
for key, joint in pending do
if joint ~= "board" then
local now_track, then_track = anim.tracks[key], baked[key]
local r1, o1 = evaluate(if now_track and not now_track.hidden then now_track else nil, studio.time)
local r0, o0 = evaluate(if then_track and not then_track.hidden then then_track else nil, studio.time)
offsets[joint] = {rotation = qnorm(qmul(qconj(r0), r1)), offset = vector.create(o1[1] - o0[1], o1[2] - o0[2], o1[3] - o0[3])}
any = true
end
end
if any then
reskate.skater.pose(offsets)
posing = true
elseif posing then
reskate.skater.pose(nil)
posing = false
end
end
local function set_time(t)
if not info then return end
studio.time = clamp(t, 0, info.duration)
reskate.animation.seek(studio.time)
end
local function use_source(clip)
if anim.source and anim.source ~= clip then reskate.animation.forget(anim.source) end
anim.source = clip
info = reskate.animation.info(clip)
studio.time = 0
baked, pending = {}, {}
preview_due = true
end
-- ---- settings, the library, the game's tricks -----------------------------------------------------------
local function save_settings() reskate.files.write("studio.json", reskate.json.encode(settings)) end
do
local ok, s = pcall(reskate.json.decode, reskate.files.read("studio.json") or "")
if ok and type(s) == "table" then
if s.in_game ~= nil then settings.in_game = s.in_game == true end
if s.room ~= nil then settings.room = s.room == true end
end
end
local library_cache, library_at = {}, -1e9
local function library()
if reskate.time.now() - library_at < 1000 then return library_cache end
library_at = reskate.time.now()
local names = {}
for _, entry in reskate.files.list("anims") or {} do
local base = string.match(entry, "^(.+)%.json$")
if base then table.insert(names, base) end
end
table.sort(names)
library_cache = names
return names
end
local function profile()
return {format = "animator-studio-plugin", version = 2, name = anim.name, trick = anim.trick, pop = anim.pop,
speed = anim.speed, loop = anim.loop, tracks = anim.tracks}
end
-- Older profiles kept angles: turned into rotations.
local function read_tracks(tracks)
for _, track in tracks or {} do
for _, k in track.keys or {} do
if not k.r and k.a then k.r = from_euler(k.a) end
k.r = k.r or {0, 0, 0, 1}
k.o = k.o or {0, 0, 0}
k.e = k.e or "smooth"
end
track.hidden = track.hidden == true
track.locked = track.locked == true
end
return tracks or {}
end
-- A saved animation on the game's trick and pop, or taken off it.
local function put_in_game(file, p, source)
if not p.trick or p.trick == "" then return false, "it has no trick" end
local pop = if p.pop and p.pop ~= "" and p.pop ~= "any" then p.pop else nil
if not settings.in_game or not source then return reskate.animation.replace(p.trick, nil, {pop = pop}) end
local ok, clip = pcall(reskate.animation.edit, source, edit_tracks(read_tracks(p.tracks)), {name = "game " .. file})
if not ok then return false, tostring(clip) end
return reskate.animation.replace(p.trick, clip, {pop = pop, speed = p.speed or 1})
end
local function each_saved(fn)
for _, file in library() do
local ok, p = pcall(reskate.json.decode, reskate.files.read("anims/" .. file .. ".json") or "")
if ok and type(p) == "table" then fn(file, p) end
end
end
local function apply_in_game()
local count = 0
library_at = -1e9
each_saved(function(file, p)
local ok, placed = pcall(put_in_game, file, p, if settings.in_game then "data:anims/" .. file .. ".fas" else nil)
if ok and placed and settings.in_game then count += 1 end
end)
return count
end
local function save(name)
name = name or anim.name
if name == "" then name = if anim.trick ~= "" then anim.trick else "Untitled" end
local file = safe(name)
if file == "" then return "A name is letters, digits, spaces, - and _." end
if not anim.source then return "Record a trick first." end
anim.name = name
local ok, why = reskate.animation.save(anim.source, "anims/" .. file .. ".fas")
if not ok then return "Not saved: " .. why end
ok, why = reskate.files.write("anims/" .. file .. ".json", reskate.json.encode(profile()))
if not ok then return "Not saved: " .. why end
reskate.animation.forget("data:anims/" .. file .. ".fas")
library_at = -1e9
put_in_game(file, profile(), anim.source)
studio.dirty = false
return "Saved " .. file .. "."
end
local function load(file)
local text = reskate.files.read("anims/" .. file .. ".json")
if not text then return "No animation is called " .. file .. "." end
local ok, p = pcall(reskate.json.decode, text)
if not ok or type(p) ~= "table" then return file .. " is not an animation." end
local data = "data:anims/" .. file .. ".fas"
reskate.animation.forget(data)
local read, why = pcall(reskate.animation.info, data)
if not read then return file .. "'s clip cannot be read: " .. tostring(why) end
anim.name, anim.trick, anim.speed = p.name or file, p.trick or "", p.speed or 1
anim.loop = p.loop ~= false
anim.pop = if p.pop == "any" then "" else (p.pop or "")
anim.tracks = read_tracks(p.tracks)
undo_stack, redo_stack = {}, {}
use_source(data)
studio.dirty = false
return "Opened " .. file .. "."
end
local function remove(file)
local ok, p = pcall(reskate.json.decode, reskate.files.read("anims/" .. file .. ".json") or "")
if ok and type(p) == "table" then put_in_game(file, p, nil) end
reskate.files.remove("anims/" .. file .. ".json")
reskate.files.remove("anims/" .. file .. ".fas")
reskate.animation.forget("data:anims/" .. file .. ".fas")
library_at = -1e9
return "Took " .. file .. " out of the library."
end
local function copy_to_pops()
if not anim.source then return "Pick a trick first." end
if anim.trick == "" then return "Give it a trick first (SAVED)." end
local name, pop = anim.name, anim.pop
local base = safe(if name ~= "" then name else anim.trick)
for _, p in {"low", "mid", "high"} do
anim.pop = p
save(base .. " " .. p)
end
anim.name, anim.pop = name, pop
return "Saved a low, a mid and a high pop copy."
end
-- The game's own tricks: its clips, read out of its data (reskate.animation.game_clips), one for each pop.
-- {the clip's family, the game's name for the trick in the air, the name shown, the category}.
local trick_table = {
{"o_ollie", "ollie", "Ollie", "Flip tricks"},
{"o_kickflip", "kickflip", "Kickflip", "Flip tricks"},
{"o_heelflip", "heelflip", "Heelflip", "Flip tricks"},
{"o_popshuv_bs", "popshuvit", "Pop Shuv-it", "Flip tricks"},
{"o_popshuv_fs", "fspopshuvit", "FS Pop Shuv-it", "Flip tricks"},
{"o_360popshuv_bs", "360popshuvit", "360 Pop Shuv-it", "Flip tricks"},
{"o_360popshuv_fs", "fs360popshuvit", "FS 360 Pop Shuv-it", "Flip tricks"},
{"o_varialflip", "varialkickflip", "Varial Kickflip", "Flip tricks"},
{"o_varialheel", "varialheelflip", "Varial Heelflip", "Flip tricks"},
{"o_360flip", "360flip", "360 Flip", "Flip tricks"},
{"o_hardflip", "hardflip", "Hardflip", "Flip tricks"},
{"o_inwardheel", "inwardheelflip", "Inward Heelflip", "Flip tricks"},
{"o_360inwardheel", "360inwardheelflip", "360 Inward Heelflip", "Flip tricks"},
{"o_360hardflip", "360hardflip", "360 Hardflip", "Flip tricks"},
{"o_laserflip", "laserflip", "Laser Flip", "Flip tricks"},
{"o_impossible", "bsimpossible", "BF Impossible", "Flip tricks"},
{"o_frontfoot_impossible", "fsimpossible", "FF Impossible", "Flip tricks"},
{"n_nollie", "nollie", "Nollie", "Nollie tricks"},
{"n_kickflip", "nolliekickflip", "Nollie Kickflip", "Nollie tricks"},
{"n_heelflip", "nollieheelflip", "Nollie Heelflip", "Nollie tricks"},
{"n_popshuv_bs", "n_popshuvit", "Nollie Pop Shuv-it", "Nollie tricks"},
{"n_popshuv_fs", "n_fspopshuvit", "Nollie FS Pop Shuv-it", "Nollie tricks"},
{"n_360popshuv_bs", "n_360popshuvit", "Nollie 360 Pop Shuv-it", "Nollie tricks"},
{"n_360popshuv_fs", "n_fs360popshuvit", "Nollie FS 360 Pop Shuv-it", "Nollie tricks"},
{"n_varialflip", "nollievarialkickflip", "Nollie Varial Kickflip", "Nollie tricks"},
{"n_varialheel", "nollievarialheelflip", "Nollie Varial Heelflip", "Nollie tricks"},
{"n_360flip", "nollie360flip", "Nollie 360 Flip", "Nollie tricks"},
{"n_hardflip", "nolliehardflip", "Nollie Hardflip", "Nollie tricks"},
{"n_inwardheel", "nollieinwardheelflip", "Nollie Inward Heelflip", "Nollie tricks"},
{"n_360inwardheel", "nollie360inwardheelflip", "Nollie 360 Inward Heelflip", "Nollie tricks"},
{"n_360hardflip", "nollie360hardflip", "Nollie 360 Hardflip", "Nollie tricks"},
{"n_laserflip", "nollielaserflip", "Nollie Laser Flip", "Nollie tricks"},
{"n_impossible", "nolliebsimpossible", "Nollie BF Impossible", "Nollie tricks"},
}
local game_tricks, game_tricks_why, game_tricks_at = nil, "the game's animations are still being read", -1e9
local function catalog()
if game_tricks then return game_tricks end
if reskate.time.now() - game_tricks_at < 1000 then return {} end
game_tricks_at = reskate.time.now()
-- An older ReSkate has no game clips: the studio works without them.
if not reskate.animation.game_clips then
game_tricks_why = "this ReSkate does not read the game's animations; update it"
return {}
end
local names, why = reskate.animation.game_clips("c_proto_onb_trick_")
if not names then
game_tricks_why = why
return {}
end
local have = {}
for _, name in names do have[name] = true end
local list = {}
for _, t in trick_table do
for _, pop in {"low", "mid", "high"} do
local asset = "animation/dingo/c_proto_onb_trick_" .. t[1] .. "_" .. pop
if have[asset] then table.insert(list, {trick = t[2], label = t[3], category = t[4], pop = pop, clip = "game:" .. asset}) end
end
end
game_tricks = list
return list
end
local function load_game_trick(t)
local data = t.clip
local ok, why = pcall(reskate.animation.info, data)
if not ok then return "The game's " .. t.label .. " cannot be read: " .. tostring(why) end
anim.name, anim.trick, anim.pop, anim.speed, anim.loop = t.label .. " " .. t.pop, t.trick, t.pop, 1, true
anim.tracks = {}
undo_stack, redo_stack = {}, {}
use_source(data)
studio.dirty = false
return "Opened the game's " .. t.label .. " (" .. t.pop .. " pop)."
end
-- ---- the camera: an orbit round the skater, placed in the free space beside the menu --------------------
local view_shift = {0, 0} -- the view's middle from the screen's, as shares of its height (right, up)
-- Where the skater is drawn (in the room, when it shows there): over his placement, at the height of his
-- hips, so a trick's rise stays in the picture. Eased, as the PR's camera is.
local eased = nil
local function focus_point()
local place, hips = reskate.skater.joint(1), reskate.skater.joint(7)
local centre = if place and hips then {place.position.x, hips.position.y + 0.05, place.position.z} else anchor
if not centre then return nil end
if face_skater and place then
local f = rotate(place.rotation, {0, 0, 1})
orbit.yaw = math.atan2(f[1], f[3]) + 0.6
face_skater = false
end
if orbit.focus ~= nil then
local at = reskate.skater.joint(orbit.focus)
if at then centre = v3(at.position) end
end
if not eased or math.abs(eased[1] - centre[1]) + math.abs(eased[3] - centre[3]) > 5 then eased = {centre[1], centre[2], centre[3]} end
for i = 1, 3 do eased[i] += (centre[i] - eased[i]) * 0.12 end
return eased
end
local function update_camera()
local centre = focus_point()
if not centre then return end
orbit.pitch = clamp(orbit.pitch, -1.35, 1.45)
orbit.distance = clamp(orbit.distance, 0.6, 12)
local cp = math.cos(orbit.pitch)
local forward = vector.normalize(vector.create(-math.sin(orbit.yaw) * cp, -math.sin(orbit.pitch), -math.cos(orbit.yaw) * cp))
local right = vector.normalize(vector.cross(forward, vector.create(0, 1, 0)))
local up = vector.cross(right, forward)
local fov = 50
local focal = 1 / (2 * math.tan(fov * math.pi / 360)) -- in screen heights
local target = vec(centre) + vec(orbit.pan)
-- The skater sits in the middle of the free space: the camera slides the other way.
local position = target - forward * orbit.distance - right * (view_shift[1] * orbit.distance / focal)
- up * (view_shift[2] * orbit.distance / focal)
reskate.camera.set(position, forward, up, fov)
camera = {position = position, forward = forward, right = right, up = up, fov = fov}
end
-- ---- open, close, record ------------------------------------------------------------------------------
local ui -- the screen's state, below
local typing_field -- the field being typed into, drawn (below)
local function finish_close()
studio.open = false
studio.confirm_close = false
reskate.ui.close()
reskate.animation.stop()
if posing then
reskate.skater.pose(nil)
posing = false
end
reskate.camera.release()
reskate.hud.hide(false)
reskate.skater.freeze(false)
if in_room or room_walk then
reskate.room.leave()
in_room, room_walk = false, false
end
end
-- Closing: the loading screen over the walk back from the room, then the studio goes.
local function begin_leave()
local t = now()
if in_room or room_walk then
reskate.room.leave()
in_room, room_walk = false, false
loading = {what = "leaving", since = t, until_ = t + 1.2, close = true}
else
loading = {what = "leaving", since = t, until_ = t + 0.8, close = true}
end
end
local last_bail = -1e9 -- now() of the skater's last bail
reskate.on("bail", function() last_bail = now() end)
local function open_studio()
if studio.open then return end
local s = reskate.skater.get()
if not s then
reskate.ui.toast("The studio needs a skater.", "warn")
return
end
-- Held still in the middle of a bail, the skater is lost when the game takes the hold away, and he
-- goes on bailing behind the studio. A bail lasts about 3.5 s.
if reskate.skater.state() == "wipeout" or now() - last_bail < 4 then
reskate.ui.toast("Get back on your board first: the studio opens once the bail is over.", "warn")
return
end
anchor = {s.position.x, s.position.y + 0.9, s.position.z}
orbit.yaw = s.heading + 0.6
eased, face_skater = nil, true
reskate.skater.freeze(true)
reskate.ui.open(title)
studio.open = true
studio.playing = false
preview_due = true
local t = now()
loading = {what = "opening", since = t, until_ = t + 1.2}
-- To the room first: the studio shows there once the walk has got there.
if settings.room then
local ok, why = reskate.room.open()
if ok then
room_walk = true
loading.until_ = t + 1.5
else
studio.status = "The character room did not open: " .. tostring(why) .. ". The studio opens where you are."
end
end
-- The game's HUD (and its menu's pages) goes once the menu is where the studio wants it: the walk
-- goes through the menu as the player would, under the loading screen.
if not room_walk then reskate.hud.hide(true) end
end
local function request_close()
if studio.dirty and anim.source then
studio.confirm_close = true
return
end
begin_leave()
end
local function record_next_trick()
local ok, why = reskate.animation.record(10)
if not ok then
studio.failure = "Cannot record: " .. why
return
end
if studio.open then finish_close() end
recording = recording or {}
recording.trick, recording.air = nil, false
end
local function cancel_recording()
recording = nil
stale_recording = true
end
local function recording_done(clip)
local trick = recording.trick
local i = reskate.animation.info(clip)
if not i.takeoff or not i.landing then
reskate.animation.forget(clip)
recording.status = "No trick was landed: still recording."
record_next_trick()
return
end
recording = nil
local cut = reskate.animation.trim(clip, math.max(0, i.takeoff - 0.6), math.min(i.duration, i.landing + 0.8))
reskate.animation.forget(clip)
anim.tracks, undo_stack, redo_stack = {}, {}, {}
anim.name, anim.trick, anim.pop = "", trick or "", ""
use_source(cut)
studio.dirty = true
studio.status = "Recorded " .. (trick or "a trick") .. "."
open_studio()
end
local function save_now() studio.status = save() end
-- ==========================================================================================================
-- The screen (the PR's animation_studio_overlay.cpp)
-- ==========================================================================================================
ui = {
selected = 7, timeline_zoom = 1, timeline_scroll = 0, rows_scroll = 0, followed_row = -1,
dragging_playhead = false, dragging_gizmo = false, orbiting = false, panning = false, drag_start = {0, 0},
key_joint = nil, key_from = 0, key_to = 0, key_moved = false,
picked = {}, -- {joint, t}
drag = nil, axis = -1, local_axes = true, move_mode = false,
message = "", message_until = 0, confirm_delete = "",
pad_mode = false, playstation = false, held_since = {}, repeat_at = {},
l3_combo = false, combo_armed = true, stick_turning = false, stick_zoom = false, scrubbing = false, scrub_time = 0,
stick_dx = 0, stick_dy = 0, stick_idle_since = 0,
tab = 1, row = 2, menu_hidden = false, warning_choice = 1, zoom_to_playhead = false,
list_scroll = {0, 0, 0, 0, 0}, list_followed = {-1, -1, -1, -1, -1}, open_trick = "", trick_row = 2,
last_mouse = {0, 0}, dialog = nil, typing = nil, number_drag = nil, last_click = {0, 0, -1}, hovered_any = false,
}
local function say(text)
ui.message = text
ui.message_until = now() + 4
end
local function is_picked(joint, t)
for _, p in ui.picked do
if same(p[1], joint) and math.abs(p[2] - t) < 1e-4 then return true end
end
return false
end
local function pick_all()
ui.picked = {}
for _, track in anim.tracks do
if not track.locked then
for _, k in track.keys do table.insert(ui.picked, {track.joint, k.t}) end
end
end
say(if #ui.picked == 0 then "There are no keys yet." else #ui.picked .. " keys chosen: drag one to move them all, Delete takes them off.")
end
local function move_picked(by)
if math.abs(by) < 1e-4 or #ui.picked == 0 then return end
table.sort(ui.picked, function(a, b) if by > 0 then return a[2] > b[2] else return a[2] < b[2] end end)
begin_change()
for _, p in ui.picked do
local to = math.max(0, p[2] + by)
move_key(p[1], p[2], to)
p[2] = to
end
end
local function delete_picked()
if #ui.picked == 0 then return end
begin_change()
for _, p in ui.picked do delete_key_at(p[1], p[2]) end
say(#ui.picked .. " keys taken off.")
ui.picked = {}
end
local function title_of()
return upper(if anim.name == "" then (if anim.trick == "" then "Untitled" else anim.trick) else anim.name)
end
local function key_joint()
if not anim.source then return end
begin_change()
local r, o = offset_of(ui.selected)
set_offset(ui.selected, r, o)
end
local function delete_key()
local k = key_here(ui.selected)
if k then
begin_change()
delete_key_at(ui.selected, k.t)
end
end
local function step_frame(frames) set_time((math.floor(studio.time * fps + 0.5) + frames) / fps) end
local function jump_key(direction)
local track = track_of(ui.selected, false)
if not track or #track.keys == 0 then return end
local best = nil
for _, k in track.keys do
if direction > 0 and k.t > studio.time + 0.5 / fps and (not best or k.t < best) then best = k.t end
if direction < 0 and k.t < studio.time - 0.5 / fps and (not best or k.t > best) then best = k.t end
end
if best then set_time(best) end
end
local function select_row(delta)
local i = joint_row[tostring(ui.selected)] or 2
ui.selected = joints[clamp(i + delta, 1, #joints)].joint
end
local function zoom_timeline(factor)
ui.timeline_zoom = clamp(ui.timeline_zoom * factor, 1, 16)
ui.zoom_to_playhead = true
end
local function copy_pose()
clipboard = {}
for key, track in anim.tracks do
local r, o = evaluate(track, studio.time)
clipboard[key] = {joint = track.joint, r = r, o = o}
end
say("Pose copied.")
end
local function paste_pose()
if not clipboard or not anim.source then return end
begin_change()
for _, c in clipboard do set_offset(c.joint, c.r, c.o) end
say("Pose pasted on this frame.")
end
local function set_track_flags(joint, hidden, locked)
local track = track_of(joint, true)
track.hidden, track.locked = hidden, locked
changed(joint)
end
local function reset_joint(joint)
local track = track_of(joint, false)
if track and not track.locked then
anim.tracks[tostring(joint)] = nil
changed(joint)
end
end
local function toggle_focus()
orbit.focus = if same(orbit.focus, ui.selected) or ui.selected == "board" then nil else ui.selected
end
-- ---- drawing -------------------------------------------------------------------------------------------
local D -- the draw object of this frame
local S, K = 1, 1 -- the window's pixels a 1080-line pixel is; the screen's own scale (the PR's k)
local measured = {}
local function sz(px) return px / S end
local function text(font, px, x, y, colour, value, align, wrap)
D:text(vector.create(x, y, 0), value, {font = font, size = sz(px), colour = colour, shadow = false, align = align,
wrap = if wrap then sz(wrap) else nil})
end
local function text_size(font, px, value)
local key = font .. px .. value
local m = measured[key]
if not m then
m = D:measure(value, {font = font, size = sz(px)})
measured[key] = m
end
return m
end
local function seed_of(x, y) return math.floor(x * 7 + y * 13) % 4294967296 end
local function rough(ax, ay, bx, by, colour, seed, scale)
D:rough_rect(vector.create(ax, ay, 0), vector.create(bx, by, 0), {colour = colour, seed = seed, scale = clamp(scale / S, 0.1, 10)})
end
local function fill(ax, ay, bx, by, colour, rounding)
D:rect(vector.create(ax, ay, 0), vector.create(bx, by, 0), {colour = colour, fill = true, rounding = if rounding then sz(rounding) else nil})
end
local function outline(ax, ay, bx, by, colour, thickness)
D:rect(vector.create(ax, ay, 0), vector.create(bx, by, 0), {colour = colour, thickness = sz(thickness)})
end
local function line(ax, ay, bx, by, colour, thickness)
D:line(vector.create(ax, ay, 0), vector.create(bx, by, 0), {colour = colour, thickness = sz(thickness)})
end
local function circle(x, y, r, colour, filled, thickness)
D:circle(vector.create(x, y, 0), r, {colour = colour, fill = filled, thickness = if thickness then sz(thickness) else nil})
end
local function poly(points, colour)
local list = {}
for _, p in points do table.insert(list, vector.create(p[1], p[2], 0)) end
D:poly(list, {colour = colour, fill = true})
end
local function diamond(x, y, r, colour) poly({{x, y - r}, {x + r, y}, {x, y + r}, {x - r, y}}, colour) end
local function bezier(p0, p1, p2, p3, colour, thickness)
local last = p0
for i = 1, 8 do
local t = i / 8
local u = 1 - t
local p = {u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1],
u * u * u * p0[2] + 3 * u * u * t * p1[2] + 3 * u * t * t * p2[2] + t * t * t * p3[2]}
line(last[1], last[2], p[1], p[2], colour, thickness)
last = p
end
end
local function halftone(x, y, columns, rows, colour) D:halftone(vector.create(x, y, 0), columns, rows, {colour = colour}) end
-- ---- the mouse, keys and pad of this frame ----------------------------------------------------------------
local mouse = {x = 0, y = 0, down = {false, false, false}, pressed = {false, false, false}, released = {false, false, false},
wheel = 0, dx = 0, dy = 0, press_at = {0, 0}, double = false}
local hovered_this = false
local function inside(ax, ay, bx, by, x, y)
x, y = x or mouse.x, y or mouse.y
return x >= ax and y >= ay and x <= bx and y <= by
end
-- A clickable box: hovered, and clicked when the press began and ended in it.
local function hit(ax, ay, bx, by)
local over = inside(ax, ay, bx, by)
if over then hovered_this = true end
return over and mouse.released[1] and inside(ax, ay, bx, by, mouse.press_at[1], mouse.press_at[2]), over
end
local keys_down, keys_pressed, keys_repeat = {}, {}, {}
local key_codes = {space = 0x20, left = 0x25, up = 0x26, right = 0x27, down = 0x28, enter = 0x0D, q = 0x51, e = 0x45, home = 0x24, ["end"] = 0x23,
z = 0x5A, y = 0x59, c = 0x43, v = 0x56, s = 0x53, a = 0x41, plus = 0xBB, numplus = 0x6B, minus = 0xBD, numminus = 0x6D, f = 0x46,
k = 0x4B, delete = 0x2E, x = 0x58, r = 0x52, g = 0x47, l = 0x4C, tab = 0x09, one = 0x31, two = 0x32, three = 0x33, four = 0x34,
five = 0x35, zero = 0x30, ctrl = 0x11, shift = 0x10, alt = 0x12, backspace = 0x08, escape = 0x1B}
local function read_keys(t)
for name, code in key_codes do
local down = reskate.input.key(code)
local was = keys_down[name]
keys_pressed[name] = down and not was
keys_repeat[name] = false
if down and not was then
keys_repeat[name] = true
ui.repeat_at["k" .. name] = t + 0.38
elseif down and t >= (ui.repeat_at["k" .. name] or 1e9) then
keys_repeat[name] = true
ui.repeat_at["k" .. name] = t + 0.07
end
keys_down[name] = down
end
end
local function read_mouse(t)
local c = reskate.input.cursor()
local was = mouse.down
local down = if c then {c.left, c.right, c.middle} else {false, false, false}
local x = if c then c.position.x else mouse.x
local y = if c then c.position.y else mouse.y
mouse.dx, mouse.dy = x - mouse.x, y - mouse.y
mouse.x, mouse.y = x, y
for i = 1, 3 do
mouse.pressed[i] = down[i] and not was[i]
mouse.released[i] = was[i] and not down[i]
end
mouse.down = down
mouse.wheel = if c then c.wheel else 0
mouse.double = false
if mouse.pressed[1] then
mouse.press_at = {x, y}
if t - ui.last_click[3] < 0.35 and math.abs(x - ui.last_click[1]) < 6 and math.abs(y - ui.last_click[2]) < 6 then mouse.double = true end
ui.last_click = {x, y, t}
end
end
local pad_names = {"up", "down", "left", "right", "start", "back", "left_stick", "right_stick", "lb", "rb", "a", "b", "x", "y"}
local pad_down, pad_pressed, pad_released = {}, {}, {}
local function read_pad(t)
local pad = reskate.input.gamepad()
if not pad then
for _, n in pad_names do pad_down[n], pad_pressed[n], pad_released[n] = false, false, false end
return nil
end
ui.playstation = pad.playstation
for _, n in pad_names do
local down = pad.buttons[n] == true
pad_pressed[n] = down and not pad_down[n]
pad_released[n] = (pad_down[n] == true) and not down
if pad_pressed[n] then ui.held_since[n] = t end
pad_down[n] = down
end
return pad
end
local function repeats(name, t)
if pad_pressed[name] then
ui.repeat_at[name] = t + 0.38
return true
end
if pad_down[name] and t >= (ui.repeat_at[name] or 1e9) then
ui.repeat_at[name] = t + 0.07
return true
end
return false
end
local function held_for(name, t) return if pad_down[name] then t - (ui.held_since[name] or t) else 0 end
-- ---- typing into the card's fields (reskate.input.text: Esc is the field's while it types) -----------------
-- The byte after `chars` characters of `text`.
local function byte_at(text_value, chars)
if chars <= 0 then return 0 end
local at = utf8.offset(text_value, chars + 1)
return (at or (#text_value + 1)) - 1
end
local function length_of(text_value) return utf8.len(text_value) or #text_value end
-- A field starts typing with all of it chosen: what is typed replaces it.
local function edit_start(id, value, max, apply)
ui.editing = {id = id, text = value, caret = length_of(value), all = true, max = max, apply = apply}
ui.blink_from = now()
end
local function edit_stop(commit)
local e = ui.editing
ui.editing = nil
reskate.input.text(false)
if commit and e then e.apply(e.text) end
end
-- What was typed and pressed this frame, into the field.
local function edit_keys(typed)
local e = ui.editing
if not e then return end
local function clear_chosen()
if e.all then e.text, e.caret, e.all = "", 0, false end
end
if typed ~= "" then
clear_chosen()
local room = e.max - length_of(e.text)
if room > 0 then
local add = typed
if length_of(add) > room then add = string.sub(add, 1, byte_at(add, room)) end
local at = byte_at(e.text, e.caret)
e.text = string.sub(e.text, 1, at) .. add .. string.sub(e.text, at + 1)
e.caret += length_of(add)
ui.blink_from = now()
end
end
if keys_repeat.backspace then
if e.all then clear_chosen()
elseif e.caret > 0 then
e.text = string.sub(e.text, 1, byte_at(e.text, e.caret - 1)) .. string.sub(e.text, byte_at(e.text, e.caret) + 1)
e.caret -= 1
end
ui.blink_from = now()
end
if keys_repeat.delete then
if e.all then clear_chosen()
elseif e.caret < length_of(e.text) then
e.text = string.sub(e.text, 1, byte_at(e.text, e.caret)) .. string.sub(e.text, byte_at(e.text, e.caret + 1) + 1)
end
end
if keys_repeat.left then e.caret, e.all = math.max(0, if e.all then 0 else e.caret - 1), false end
if keys_repeat.right then e.caret, e.all = math.min(length_of(e.text), e.caret + 1), false end
if keys_pressed.home then e.caret, e.all = 0, false end
if keys_pressed["end"] then e.caret, e.all = length_of(e.text), false end
if keys_down.ctrl and keys_pressed.a then e.all = true end
if keys_pressed.enter then edit_stop(true)
elseif keys_pressed.escape then edit_stop(false) end
end
-- ---- controller glyphs and prompts -----------------------------------------------------------------------
local function glyph(x, y, g)
local k = K
local r = 11 * k
local cx, cy = x + r, y + r
local function label(s, w)
fill(x, y + 2 * k, x + w, y + 2 * r - 2 * k, white, 5 * k)
text("bold", 11 * k, x + w * 0.5, cy - 7 * k, black, s, "centre")
return w
end
if g == "a" or g == "b" or g == "x" or g == "y" then
circle(cx, cy, r, white, true)
if not ui.playstation then
text("bold", 14 * k, cx, cy - 9 * k, black, upper(g), "centre")
else
local s, w = r * 0.48, 2.2 * k
if g == "a" then
line(cx - s, cy - s, cx + s, cy + s, black, w)
line(cx - s, cy + s, cx + s, cy - s, black, w)
elseif g == "b" then circle(cx, cy, s, black, false, w)
elseif g == "x" then outline(cx - s, cy - s, cx + s, cy + s, black, w)
else
line(cx, cy - s, cx + s, cy + s * 0.75, black, w)
line(cx + s, cy + s * 0.75, cx - s, cy + s * 0.75, black, w)
line(cx - s, cy + s * 0.75, cx, cy - s, black, w)
end
end
return 2 * r
end
local ps = ui.playstation
if g == "lb" then return label(if ps then "L1" else "LB", 30 * k) end
if g == "rb" then return label(if ps then "R1" else "RB", 30 * k) end
if g == "lt" then return label(if ps then "L2" else "LT", 30 * k) end
if g == "rt" then return label(if ps then "R2" else "RT", 30 * k) end
if g == "l3" then return label("L3", 30 * k) end
if g == "r3" then return label("R3", 30 * k) end
if g == "menu" then return label(if ps then "OPT" else "MENU", 40 * k) end
if g == "view" then return label(if ps then "SHARE" else "VIEW", 44 * k) end
if g == "ls" or g == "rs" then
circle(cx, cy, r, white, false, 2 * k)
text("bold", 12 * k, cx, cy - 8 * k, white, if g == "ls" then "L" else "R", "centre")
return 2 * r
end
if g == "dpad_ud" or g == "dpad_lr" then
local a, t = r * 0.9, r * 0.34
fill(cx - t, cy - a, cx + t, cy + a, if g == "dpad_ud" then white else with_alpha(white, 90 / 255))
fill(cx - a, cy - t, cx + a, cy + t, if g == "dpad_lr" then white else with_alpha(white, 90 / 255))
return 2 * r
end
return 0
end
local function key_cap(x, y, key)
local w = text_size("bold", 11 * K, key).x
fill(x, y + 2 * K, x + w + 12 * K, y + 20 * K, white, 3 * K)
text("bold", 11 * K, x + 6 * K, y + 4 * K, black, key)
return w + 12 * K
end
local function prompt(x, y, g, key, what)
local w = if ui.pad_mode and g then glyph(x, y, g) else key_cap(x, y, key)
text("bold", 13 * K, x + w + 7 * K, y + 3 * K, white, what)
return w + 7 * K + text_size("bold", 13 * K, what).x + 20 * K
end
-- ---- turning the chosen joint (mouse drag or left stick) ---------------------------------------------------
-- The joint in the world (with its edits), and its parent's turn and scale.
local function joint_world(joint)
if joint == "board" then
if not info or not info.board or not anim.source then return nil end
local place = reskate.skater.joint(1)
local ok, b = pcall(reskate.animation.sample, baked_clip or anim.source, "board", studio.time)
if not place or not ok or not b then return nil end
local pr = place.rotation
local p = rotate(pr, {b.position.x * place.scale, b.position.y * place.scale, b.position.z * place.scale})
return {position = {place.position.x + p[1], place.position.y + p[2], place.position.z + p[3]}, rotation = qmul(pr, b.rotation),
parent_rotation = pr, parent_scale = place.scale}
end
local at = reskate.skater.joint(joint)
if not at then return nil end
local parent = if at.parent then reskate.skater.joint(at.parent) else nil
return {position = v3(at.position), rotation = at.rotation, parent_rotation = if parent then parent.rotation else {0, 0, 0, 1},
parent_scale = if parent then parent.scale else 1}
end
local function begin_turn()
local sel = joint_world(ui.selected)
if not sel then return false end
local r, o = offset_of(ui.selected)
ui.drag = {r = r, o = o, world = sel.rotation, parent = sel.parent_rotation, parent_scale = sel.parent_scale}
begin_change()
set_playing(false)
return true
end
-- `dx`, `dy`: how far the turn has gone, in screen pixels at 1080 lines.
local function apply_turn(dx, dy, move)
local d = ui.drag
if not d or not camera then return end
local right, up = v3(camera.right), v3(camera.up)
local r, o = d.r, {d.o[1], d.o[2], d.o[3]}
local radians = 0.0085
if move and moves(ui.selected) then
local metres = 1 / 400
local world = {}
for i = 1, 3 do world[i] = right[i] * dx * metres - up[i] * dy * metres end
if ui.axis >= 0 then
for i = 1, 3 do
if i ~= ui.axis + 1 then world[i] = 0 end
end
end
local l = rotate(qconj(d.parent), world)
for i = 1, 3 do o[i] += l[i] / math.max(d.parent_scale, 0.01) end
else
local turn
if ui.axis >= 0 then
local axis = {0, 0, 0}
axis[ui.axis + 1] = 1
local q = axis_angle(axis, (dx - dy) * radians)
turn = if ui.local_axes then q else qmul(qmul(qconj(d.world), q), d.world)
else
local world = qmul(axis_angle(up, dx * radians), axis_angle(right, dy * radians))
turn = qmul(qmul(qconj(d.world), world), d.world)
end
r = qnorm(qmul(d.r, turn))
if ui.selected == "board" then
-- The board's keys turn it about the ground's axes.
local ground
if ui.axis >= 0 then
local axis = {0, 0, 0}
axis[ui.axis + 1] = 1
ground = axis_angle(axis, (dx - dy) * radians)
else
local world = qmul(axis_angle(up, dx * radians), axis_angle(right, dy * radians))
ground = qmul(qmul(qconj(d.parent), world), d.parent)
end
r = qnorm(qmul(ground, d.r))
end
end
set_offset(ui.selected, r, o)
end
-- ---- the menu: skate.'s own list menu (Trick List), with tabs -----------------------------------------------
local tab_names = {"TRICKS", "SAVED", "JOINTS", "KEYS", "PLAYBACK"}
local record_about = "Skate and land the trick you want: you are back in the studio with it, the board where the game had it. Esc: back without one."
local function rows_of(tab)
local rows = {}
local function row(label, value, about, act, enabled, on, saved)
table.insert(rows, {label = label, value = value or "", about = about or "", act = act, enabled = enabled ~= false, on = on, library = saved})
end
if tab == 1 then
local list = catalog()
if ui.open_trick ~= "" then
local label = ""
for _, t in list do
if t.trick == ui.open_trick then label = t.label end
end
row("< Back", "", "Back to the list of tricks.", function()
ui.open_trick = ""
ui.row = ui.trick_row
end)
row(upper(label), "", "", nil, false)
for _, t in list do
if t.trick == ui.open_trick then
local pop = if t.pop == "low" then "Low pop" elseif t.pop == "mid" then "Mid pop" else "High pop"
row(pop, "", "Opens the game's " .. t.label .. " with a " .. t.pop .. " pop to change it. Save it and it plays on your " .. t.label .. "s. "
.. "In flips the game places the board with its physics, which these clips do not hold.", function() say(load_game_trick(t)) end,
true, anim.trick == t.trick and anim.pop == t.pop)
end
end
return rows
end
row("RECORD", "", "", nil, false)
row("Record a trick", "", record_about, record_next_trick)
local category, trick = nil, nil
for _, t in list do
if t.category ~= category then
category = t.category
row(upper(category or "GAME TRICKS"), "", "", nil, false)
end
if t.trick ~= trick then
trick = t.trick
local index = #rows + 1
row(t.label, ">", "Choose the pop next: low, mid or high.", function()
ui.open_trick = t.trick
ui.trick_row = index
ui.row = 3
end, true, anim.trick == t.trick)
end
end
elseif tab == 2 then
local names = library()
if #names == 0 then row("Nothing saved yet", "", "Pick a trick in TRICKS, change it and save it: it shows up here.", nil, false) end
for _, name in names do
local value = if safe(anim.name) == name then "open" else ""
if ui.confirm_delete == name then value = "delete again" end
row(name, value, "A saved animation: choose it to open it. Delete takes it out of the library (press it twice).",
function() say(load(name)) end, true, safe(anim.name) == name, name)
end
elseif tab == 3 then
for _, j in joints do
local track = track_of(j.joint, false)
local count = if track then #track.keys else 0
local value = if count > 0 then count .. (if count == 1 then " key" else " keys") else ""
if track and track.hidden then value ..= " off" end
if track and track.locked then value ..= " locked" end
row(j.label, value, "", function() ui.selected = j.joint end)
end
elseif tab == 4 then
local key = key_here(ui.selected)
local track = track_of(ui.selected, false)
local locked = track ~= nil and track.locked
local joint = upper(label_of(ui.selected))
local has_keys = track ~= nil and #track.keys > 0
row("Key this frame", joint, "Keys the joint as it is now on this frame.", key_joint, anim.source ~= nil and not locked)
row("Delete key", "", "Takes the joint's key on this frame off.", delete_key, key ~= nil and not locked)
row("Select all keys", if #ui.picked == 0 then "Ctrl A" else tostring(#ui.picked),
"Chooses every key of every joint and the board: drag one to move them all, Delete takes them off. Shift + click adds or takes one key; Esc lets go.",
pick_all, anim.source ~= nil)
row("Delete chosen keys", "", "Takes every chosen key off.", delete_picked, #ui.picked > 0)
row("Previous key", "", "The playhead to the joint's key before this frame.", function() jump_key(-1) end, has_keys)
row("Next key", "", "The playhead to the joint's key after this frame.", function() jump_key(1) end, has_keys)
if key then
local names = {smooth = "Smooth", linear = "Linear", step = "Step"}
local next_ease = {smooth = "linear", linear = "step", step = "smooth"}
row("Blend into the next key", names[key.e], "Smooth eases in and out, Linear goes straight, Step holds this key until the next.", function()
begin_change()
key.e = next_ease[key.e]
changed(ui.selected)
end, not locked)
end
row("Turn about", if ui.axis == 0 then "X" elseif ui.axis == 1 then "Y" elseif ui.axis == 2 then "Z" else "any axis",
"Which axis a drag or the left stick turns the joint about.", function() ui.axis = if ui.axis >= 2 then -1 else ui.axis + 1 end)
row("Axes", if ui.local_axes then "the joint's own" else "the world's", "X, Y and Z of the joint itself or of the world.",
function() ui.local_axes = not ui.local_axes end)
row("Follow joint", "", "The camera keeps the joint in the middle (again: the whole skater).", toggle_focus)
row("Copy pose", "", "Remembers every joint's edit on this frame.", copy_pose)
row("Paste pose", "", "Keys the remembered pose on this frame.", paste_pose, anim.source ~= nil)
row("Reset joint", joint, "Takes all of the joint's keys off.", function()
begin_change()
reset_joint(ui.selected)
end, track ~= nil and not locked)
row("Hide joint's keys", if track and track.hidden then "hidden" else "", "Plays the trick as if the joint had no keys.",
function() set_track_flags(ui.selected, not (track ~= nil and track.hidden), locked) end)
row("Lock joint", if locked then "locked" else "", "A locked joint's keys cannot be changed.",
function() set_track_flags(ui.selected, track ~= nil and track.hidden, not locked) end)
row("Undo", "", "", undo, #undo_stack > 0)
row("Redo", "", "", redo, #redo_stack > 0)
else
row(if studio.playing then "Pause" else "Play", "", "Plays the trick with your keys.", function() set_playing(not studio.playing) end, anim.source ~= nil)
row("Loop", if anim.loop then "on" else "off", "", function()
anim.loop = not anim.loop
preview_due = true
end)
row("Speed", string.format("%.2fx", anim.speed), "How fast the trick plays, here and in game. Choose it to step through the speeds.", function()
local speeds = {0.25, 0.5, 0.75, 1, 1.25, 1.5, 2}
local nxt = speeds[1]
for _, s in speeds do
if s > anim.speed + 0.01 then nxt = s break end
end
anim.speed = nxt
studio.dirty = true
preview_due = true
end)
row("Studio room", if settings.room then "on" else "off",
"On: the studio opens in the game's own lit studio room, as skate.'s Skaters page shows it. Off: it opens where you are. Takes effect the next time it opens.",
function()
settings.room = not settings.room
save_settings()
studio.status = if settings.room then "From the next time it opens, the studio opens in its room."
else "From the next time it opens, the studio opens where you are."
end)
row("Custom tricks in game", if settings.in_game then "on" else "off",
"On: when you do a trick you saved an animation for (its TRICK, as ollie or kickflip), yours plays instead of the game's.", function()
settings.in_game = not settings.in_game
save_settings()
apply_in_game()
end)
row("Pop", if anim.pop == "" then "any" else anim.pop,
"Which pop this plays on: any, or only a low, mid or high pop (one of each can be saved for a trick). Choose it to step through.", function()
anim.pop = if anim.pop == "" then "low" elseif anim.pop == "low" then "mid" elseif anim.pop == "mid" then "high" else ""
studio.dirty = true
end, anim.source ~= nil)
row("Copy to low, mid and high pop", "", "Saves this trick three times, one for each pop, to change each on its own. In game every pop is timed to its own air.",
function() say(copy_to_pops()) end, anim.source ~= nil)
row("Save", if studio.dirty then "not saved" else "", "Saves the animation in the library.", save_now, anim.source ~= nil)
row("Close the studio", "", "Back to skating. The camera and the game's HUD come back.", request_close)
end
return rows
end
local function switch_tab(delta)
ui.tab = (ui.tab - 1 + delta) % #tab_names + 1
ui.row = if ui.tab == 1 then (if ui.open_trick == "" then 2 else 3) else 1
ui.confirm_delete = ""
end
local function move_row(delta)
if ui.tab == 3 then
select_row(delta)
return
end
local rows = rows_of(ui.tab)
local count = #rows
local at = ui.row
for _ = 1, count do
at = clamp(at + delta, 1, math.max(1, count))
if rows[at] and (rows[at].act or at == 1 or at == count) then break end
end
if count > 0 and not rows[at].act then at = ui.row end
ui.row = at
end
local function activate_row()
if ui.tab == 3 then
key_joint()
return
end
local r = rows_of(ui.tab)[ui.row]
if r and r.enabled and r.act then r.act() end
end
local function delete_row()
local r = rows_of(ui.tab)[ui.row]
if not r or not r.library then return end
if ui.confirm_delete == r.library then
say(remove(r.library))
ui.confirm_delete = ""
else
ui.confirm_delete = r.library
end
end
-- ---- the controller ----------------------------------------------------------------------------------------
local function controller(pad, t, dt)
if not pad then return end
local any = false
for _, n in pad_names do any = any or pad_pressed[n] end
local sticks = math.abs(pad.left_x) > 0.35 or math.abs(pad.left_y) > 0.35 or math.abs(pad.right_x) > 0.35 or math.abs(pad.right_y) > 0.35
or pad.left_trigger > 0.4 or pad.right_trigger > 0.4
if any or sticks then ui.pad_mode = true end
-- L3 + R3 held: close the studio (the same hold opens it from the game).
if pad_down.left_stick and pad_down.right_stick then
ui.l3_combo = true
if ui.combo_armed and held_for("left_stick", t) > 0.5 and held_for("right_stick", t) > 0.5 then
request_close()
ui.combo_armed = false
end
return
end
if ui.l3_combo then
if not pad_down.left_stick and not pad_down.right_stick then
ui.l3_combo = false
ui.combo_armed = true
end
return
end
if studio.confirm_close then return end
if pad_pressed.lb then switch_tab(-1) end
if pad_pressed.rb then switch_tab(1) end
if pad_pressed.a then activate_row() end
if pad_pressed.x then
if ui.tab == 2 then delete_row() else delete_key() end
end
if pad_pressed.y then set_playing(not studio.playing) end
if pad_pressed.b then undo() end
if pad_pressed.back then
if ui.timeline_zoom > 1.01 then ui.timeline_zoom = 1 else zoom_timeline(3) end
end
if pad_pressed.start then save_now() end
-- L3: turn about any axis, X, Y, Z, then move (the hips and the board).
if pad_released.left_stick and t - (ui.held_since.left_stick or t) < 0.5 then
if ui.move_mode then ui.move_mode, ui.axis = false, -1
elseif ui.axis >= 2 then ui.move_mode, ui.axis = true, -1
else ui.axis += 1 end
end
if pad_released.right_stick and t - (ui.held_since.right_stick or t) < 0.5 then ui.stick_zoom = not ui.stick_zoom end
if repeats("up", t) then move_row(-1) end
if repeats("down", t) then move_row(1) end
-- RT plays forward and LT backward, as fast as they are pressed (fully: real time), to watch it.
local scrub = pad.right_trigger - pad.left_trigger
if math.abs(scrub) > 0.08 and anim.source then
local sign = if scrub > 0 then 1 else -1
local amount = (math.abs(scrub) - 0.08) / 0.92
ui.scrub_time = clamp((if ui.scrubbing then ui.scrub_time else studio.time) + sign * amount * amount * dt, 0, info.duration)
ui.scrubbing = true
set_playing(false)
set_time(ui.scrub_time)
elseif ui.scrubbing then
ui.scrubbing = false
set_time(math.floor(studio.time * fps + 0.5) / fps)
end
if repeats("left", t) then step_frame(-1) end
if repeats("right", t) then step_frame(1) end
-- Left stick turns the chosen joint (or moves the hips or the board); a turn is one undo step.
local dz = 0.22
local lx = if math.abs(pad.left_x) > dz then pad.left_x else 0
local ly = if math.abs(pad.left_y) > dz then pad.left_y else 0
if lx ~= 0 or ly ~= 0 then
local track = track_of(ui.selected, false)
if not ui.stick_turning and anim.source and not (track and track.locked) and begin_turn() then
ui.stick_turning = true
ui.stick_dx, ui.stick_dy = 0, 0
end
if ui.stick_turning then
ui.stick_dx += lx * 300 * dt
ui.stick_dy -= ly * 300 * dt
apply_turn(ui.stick_dx, ui.stick_dy, ui.move_mode)
end
ui.stick_idle_since = t
elseif ui.stick_turning and t - ui.stick_idle_since > 0.25 then
ui.stick_turning = false
end
-- Right stick orbits; after R3 it zooms (up / down) and pans (left / right).
local rx = if math.abs(pad.right_x) > dz then pad.right_x else 0
local ry = if math.abs(pad.right_y) > dz then pad.right_y else 0
if rx ~= 0 or ry ~= 0 then
if ui.stick_zoom then
orbit.distance *= 1 - ry * 1.6 * dt
if camera then
local r = v3(camera.right)
for i = 1, 3 do orbit.pan[i] += r[i] * rx * orbit.distance * 0.5 * dt end
end
else
orbit.yaw -= rx * 2.2 * dt
orbit.pitch -= ry * 1.6 * dt
end
end
end
-- ---- the parts of the screen ---------------------------------------------------------------------------
-- The page title, brushed like the game's ("TRICK LIST"), with its spray of dots.
local function page_title(x, y)
local k = K
halftone(x - 18 * k, y + 6 * k, 3, 9, rgba(255, 255, 255, 70))
text("title_large", 74 * k, x, y, white, "ANIMATOR")
local w = text_size("title_large", 74 * k, "ANIMATOR")
text("title_large", 74 * k, x + w.x + 22 * k, y, white, "STUDIO")
local w2 = text_size("title_large", 74 * k, "STUDIO")
halftone(x + w.x + w2.x + 30 * k, y + 10 * k, 3, 7, rgba(255, 255, 255, 60))
text("bold", 15 * k, x + 6 * k, y - 14 * k, blue_light, "RESKATE")
end
-- The tab row: Q / E (LB / RB) either side, the chosen tab outlined with a blue brush stroke under it.
local function tab_row(x0, y)
local k = K
local h, fs = 36 * k, 25 * k
local x = x0
local function cap(key, g)
if ui.pad_mode then
x += glyph(x, y + 7 * k, g) + 12 * k
else
outline(x, y + 7 * k, x + 22 * k, y + 29 * k, white, 2 * k)
text("bold", 13 * k, x + 11 * k, y + 10 * k, white, key, "centre")
x += 22 * k + 14 * k
end
end
cap("Q", "lb")
for i, name in tab_names do
local t = text_size("heading", fs, name)
local ax, ay, bx, by = x, y, x + t.x + 20 * k, y + h
if hit(ax, ay, bx, by) then
ui.tab = i
ui.row = 1
end
local chosen = ui.tab == i
rough(ax, ay, bx, by, if chosen then rgba(22, 22, 24, 250) else rgba(26, 26, 28, 236), seed_of(ax, ay), k * 0.6)
if chosen then
outline(ax - 3 * k, ay - 3 * k, bx + 3 * k, by + 3 * k, white, 2.6 * k)
bezier({ax - 2 * k, by + 6 * k}, {ax + (bx - ax) * 0.3, by + 9 * k}, {ax + (bx - ax) * 0.7, by + 3 * k}, {bx + 4 * k, by + 5 * k}, blue, 4 * k)
end
text("heading", fs, ax + 10 * k, ay + (h - t.y) * 0.5, white, name)
x = bx + 14 * k
end
cap("E", "rb")
end
-- The list: dark rough rows; the chosen one a blue brush stroke with a screen-print fade at its end.
local function list(x0, y0, w, h)
local k = K
local row_h, gap = 50 * k, 8 * k
local rows = rows_of(ui.tab)
local chosen = ui.row
if ui.tab == 3 then chosen = joint_row[tostring(ui.selected)] or 1 end
chosen = clamp(chosen, 1, math.max(1, #rows))
if ui.tab ~= 3 then ui.row = chosen end
rough(x0 - 18 * k, y0 - 10 * k, x0 + w + 18 * k, y0 + h + 10 * k, rgba(48, 50, 56, 150), 61, k)
local content = #rows * (row_h + gap)
local scroll = ui.list_scroll[ui.tab]
if ui.list_followed[ui.tab] ~= chosen then
ui.list_followed[ui.tab] = chosen
local top = (chosen - 1) * (row_h + gap)
if top < scroll then scroll = top end
if top + row_h > scroll + h then scroll = top + row_h - h end
end
if inside(x0, y0, x0 + w, y0 + h) and mouse.wheel ~= 0 then scroll -= mouse.wheel * (row_h + gap) end
scroll = clamp(scroll, 0, math.max(0, content - h))
ui.list_scroll[ui.tab] = scroll
D:clip(vector.create(x0, y0, 0), vector.create(x0 + w + 4 * k, y0 + h, 0))
for i, r in rows do
local y = y0 + (i - 1) * (row_h + gap) - scroll
if y + row_h >= y0 and y <= y0 + h then
local ax, ay, bx, by = x0, y, x0 + w - 24 * k, y + row_h
local clicked, hovered = hit(ax, math.max(ay, y0), bx, math.min(by, y0 + h))
local here = i == chosen
if here then
rough(ax, ay, bx, by, blue, seed_of(ax, ay), k * 1.2)
halftone(bx - 70 * k, ay + 8 * k, 9, 5, rgba(255, 255, 255, 46))
D:scribble(vector.create(ax, ay - 2 * k, 0), vector.create(ax + 4 * k, by + 2 * k, 0), {seed = 7 + i, scale = clamp(k / S, 0.1, 10)})
else
rough(ax, ay, bx, by, if hovered then rgba(40, 41, 44, 246) else rgba(24, 24, 26, 246), seed_of(ax, ay), k)
fill(ax + 2 * k, by, bx - 2 * k, by + 3 * k, rgba(0, 0, 0, 120))
end
text("bold", 21 * k, ax + 30 * k, ay + row_h * 0.5 - 12 * k, if r.enabled then white else faint, r.label)
if r.value ~= "" then text("bold", 16 * k, bx - 22 * k, ay + row_h * 0.5 - 9 * k, if here then white else soft, r.value, "right") end
if r.on and not here then fill(ax, ay + 6 * k, ax + 4 * k, by - 6 * k, blue) end
if clicked then
if ui.tab == 3 then ui.selected = joints[i].joint
else
ui.row = i
if r.enabled and r.act then r.act() end
end
end
end
end
D:clip()
-- The thin scroll bar at the right, as the game's lists have.
local bar_x = x0 + w - 8 * k
fill(bar_x, y0, bar_x + 3 * k, y0 + h, rgba(220, 220, 225, 120))
if content > h then
local bh = math.max(30 * k, h * h / content)
local y = y0 + (h - bh) * scroll / (content - h)
fill(bar_x - 4 * k, y, bar_x + 7 * k, y + bh, rgba(235, 235, 240), 2 * k)
end
end
-- The field being typed into, drawn: its text (all of it chosen, at first), the caret blinking after
-- the last character typed. A click outside it ends the typing and keeps what was typed.
typing_field = function(id, ax, ay, w, h, px, ink, over, align)
local k = K
local e = ui.editing
e.seen = true
fill(ax, ay, ax + w, ay + h, rgba(215, 230, 250), 2 * k)
outline(ax, ay, ax + w, ay + h, blue, 1.6 * k)
D:clip(vector.create(ax, ay, 0), vector.create(ax + w - 4 * k, ay + h, 0))
local full = text_size("bold", px, e.text)
local x = if align == "centre" then ax + (w - full.x) * 0.5 else ax + 8 * k
local y = ay + (h - px) * 0.5 - 1 * k
if e.all and e.text ~= "" then fill(x - 2 * k, y, x + full.x + 2 * k, y + px + 2 * k, blue_dim) end
text("bold", px, x, y, ink, e.text)
if not e.all and math.floor((now() - (ui.blink_from or 0)) * 2) % 2 == 0 then
local before = text_size("bold", px, string.sub(e.text, 1, byte_at(e.text, e.caret))).x
line(x + before + 1 * k, y, x + before + 1 * k, y + px + 2 * k, ink, 1.6 * k)
end
D:clip()
if mouse.pressed[1] and not over then edit_stop(true) end
end
-- A labelled number that drags left and right, and takes typing on a double click.
local function number(id, x, y, w, label, value, speed, format, ink, on_change)
local k = K
text("bold", 14 * k, x, y + 4 * k, ink, label)
local ax, ay, bx, by = x + 22 * k, y, x + 22 * k + w, y + 26 * k
local _, over = hit(ax, ay, bx, by)
if ui.editing and ui.editing.id == id then
typing_field(id, ax, ay, bx - ax, by - ay, 14 * k, ink, over, "centre")
return
end
local active = ui.number_drag ~= nil and ui.number_drag.id == id
fill(ax, ay, bx, by, if active then rgba(150, 200, 255) elseif over then rgba(190, 192, 196) else rgba(205, 205, 204), 2 * k)
text("bold", 14 * k, (ax + bx) * 0.5, ay + 5 * k, ink, string.format(format, value), "centre")
if over and mouse.double then
ui.number_drag = nil
edit_start(id, string.format(format, value), 16, function(typed)
local n = tonumber(typed)
if n then
begin_change()
on_change(n)
end
end)
return
end
if over and mouse.pressed[1] then ui.number_drag = {id = id, start = mouse.x, value = value} end
if active then
if mouse.down[1] then
local next_value = ui.number_drag.value + (mouse.x - ui.number_drag.start) / k * speed
if math.abs(next_value - value) > 1e-6 then on_change(next_value) end
else
ui.number_drag = nil
end
end
end
-- The white card under the list, as the game's trick description: the chosen joint's numbers, or what
-- the highlighted row does.
local function card(x, y0, w, h)
local k = K
rough(x + 6 * k, y0 + 6 * k, x + w + 6 * k, y0 + h + 6 * k, rgba(0, 0, 0, 140), 71, k)
rough(x, y0, x + w, y0 + h, rgba(234, 234, 232, 252), 73, k)
halftone(x + w - 40 * k, y0 + 10 * k, 4, 10, rgba(0, 0, 0, 22))
local ink, ink_soft = rgba(20, 20, 22), rgba(90, 90, 96)
local y = y0 + 16 * k
if ui.tab == 3 or ui.tab == 4 then
text("heading", 22 * k, x + 22 * k, y, ink, upper(label_of(ui.selected)))
local key = key_here(ui.selected)
text("bold", 13 * k, x + w - 22 * k, y + 6 * k, if key then blue else ink_soft, if key then "KEY ON THIS FRAME" else "NO KEY ON THIS FRAME", "right")
y += 40 * k
local r, o = offset_of(ui.selected)
local euler, position = to_euler(r), {o[1], o[2], o[3]}
-- The board: where it is and how it is turned, from where it starts on the ground.
local base = nil
if ui.selected == "board" and anim.source and info.board then
local at = reskate.animation.sample(anim.source, "board", studio.time)
local start = reskate.animation.sample(anim.source, "board", 0)
if at and start then
base = {position = {at.position.x - start.position.x, at.position.y - start.position.y, at.position.z - start.position.z},
rotation = qmul(qconj(start.rotation), at.rotation)}
for i = 1, 3 do position[i] = base.position[i] + o[i] end
euler = to_euler(qmul(r, base.rotation))
end
end
local track = track_of(ui.selected, false)
local locked = track ~= nil and track.locked
local col_w = (w - 44 * k) / 3
local axes = {"X", "Y", "Z"}
local function edit_change(next_euler, next_position)
if locked or not anim.source then return end
if now() - last_change > 0.4 then begin_change() end
last_change = now()
if base then
local no = {}
for i = 1, 3 do no[i] = next_position[i] - base.position[i] end
set_offset(ui.selected, qnorm(qmul(from_euler(next_euler), qconj(base.rotation))), no)
else
set_offset(ui.selected, from_euler(next_euler), next_position)
end
end
text("bold", 13 * k, x + 22 * k, y, ink_soft, if base then "ROTATION (0, 0, 0 = AS IT STARTS)" else "ROTATION")
for i = 1, 3 do
number("r" .. i, x + 22 * k + (i - 1) * col_w, y + 18 * k, col_w - 34 * k, axes[i], euler[i], 0.4, "%.1f", ink, function(v)
local e = {euler[1], euler[2], euler[3]}
e[i] = v
edit_change(e, position)
end)
end
if moves(ui.selected) then
text("bold", 13 * k, x + 22 * k, y + 52 * k, ink_soft, if base then "POSITION (0, 0, 0 = WHERE IT STARTS, ON THE GROUND)" else "POSITION")
for i = 1, 3 do
number("p" .. i, x + 22 * k + (i - 1) * col_w, y + 70 * k, col_w - 34 * k, axes[i], position[i], 0.002, "%.3f", ink, function(v)
local p = {position[1], position[2], position[3]}
p[i] = v
edit_change(euler, p)
end)
end
end
if ui.tab == 4 then
local r2 = rows_of(ui.tab)[ui.row]
if r2 then text("bold", 14 * k, x + 22 * k, y0 + h - 30 * k, ink_soft, r2.about, nil, w - 44 * k) end
end
return
end
if ui.tab == 2 then
-- The name and trick of the one being edited, typed here.
local fw = (w - 60 * k) / 2
text("bold", 13 * k, x + 22 * k, y, ink_soft, "NAME")
text("bold", 13 * k, x + 38 * k + fw, y, ink_soft, "TRICK")
local fields = {
{x + 22 * k, anim.name, "name", 40, function(v)
if v ~= anim.name then
anim.name = v
studio.dirty = true
end
end},
{x + 38 * k + fw, anim.trick, "trick", 48, function(v)
v = string.lower(v)
if v ~= anim.trick then
anim.trick = v
studio.dirty = true
end
end},
}
for _, field in fields do
local ax, ay = field[1], y + 18 * k
local clicked, over = hit(ax, ay, ax + fw, ay + 30 * k)
if ui.editing and ui.editing.id == field[3] then
typing_field(field[3], ax, ay, fw, 30 * k, 16 * k, ink, over)
else
fill(ax, ay, ax + fw, ay + 30 * k, if over then rgba(195, 197, 200) else rgba(205, 205, 204), 2 * k)
D:clip(vector.create(ax, ay, 0), vector.create(ax + fw - 6 * k, ay + 30 * k, 0))
text("bold", 16 * k, ax + 8 * k, ay + 6 * k, ink, field[2])
D:clip()
if clicked then edit_start(field[3], field[2], field[4], field[5]) end
end
end
y += 62 * k
end
local r3 = rows_of(ui.tab)[ui.row]
if r3 then text("bold", 19 * k, x + 22 * k, y, ink, r3.about, nil, w - 44 * k) end
end
-- A flat button: rough tile, white type, blue when on. Returns whether it was clicked.
local function button(x, y, w, h, label, on, enabled)
enabled = enabled ~= false
local clicked, over = hit(x, y, x + w, y + h)
clicked = clicked and enabled
over = over and enabled
rough(x, y, x + w, y + h, if on then blue elseif over then rgba(64, 66, 72, 240) else rgba(34, 35, 38, 240), seed_of(x, y), K * 0.7)
text("bold", 14 * K, x + w * 0.5, y + (h - 16 * K) * 0.5, if enabled then white else faint, label, "centre")
return clicked
end
-- The bottom of the screen: the whole trick from start to end, one row of keys per joint with a row of
-- every key on top, the playhead across all of them.
local function timeline(x0, y0, w, h)
local k = K
local label_w, header_h, summary_h, row_h, gap = 280 * k, 70 * k, 30 * k, 40 * k, 4 * k
local has_clip = anim.source ~= nil
rough(x0 - 10 * k, y0, x0 + w + 10 * k, y0 + h + 10 * k, rgba(14, 14, 16, 236), 81, k * 1.4)
text("title", 30 * k, x0 + 20 * k, y0 + 4 * k, white, title_of())
local bx, by = x0 + 20 * k, y0 + 42 * k
local cw, ch = 30 * k, 22 * k
if button(bx, by, cw, ch, "|<", false, has_clip) then set_time(0) end
bx += cw + 4 * k
if button(bx, by, cw, ch, "<", false, has_clip) then step_frame(-1) end
bx += cw + 4 * k
if button(bx, by, 62 * k, ch, if studio.playing then "PAUSE" else "PLAY", studio.playing, has_clip) then set_playing(not studio.playing) end
bx += 62 * k + 4 * k
if button(bx, by, cw, ch, ">", false, has_clip) then step_frame(1) end
bx += cw + 4 * k
if button(bx, by, cw, ch, ">|", false, has_clip) then set_time(if info then info.duration else 0) end
bx += cw + 4 * k
if button(bx, by, 40 * k, ch, "LOOP", anim.loop) then
anim.loop = not anim.loop
preview_due = true
end
local duration = math.max(if info then info.duration else 0, 0.5)
local pad = 22 * k
do
local zx = x0 + w - 16 * k - 3 * 34 * k
if button(zx, y0 + 6 * k, 30 * k, 24 * k, "-", false, ui.timeline_zoom > 1) then zoom_timeline(1 / 1.5) end
zx += 34 * k
if button(zx, y0 + 6 * k, 30 * k, 24 * k, "FIT", ui.timeline_zoom <= 1.001) then ui.timeline_zoom = 1 end
zx += 34 * k
if button(zx, y0 + 6 * k, 30 * k, 24 * k, "+", false, ui.timeline_zoom < 16) then zoom_timeline(1.5) end
if has_clip then
text("bold", 13 * k, x0 + w - 16 * k, y0 + 36 * k, soft,
string.format("%.2f / %.2f s frame %d", studio.time, info.duration, math.floor(studio.time * fps + 0.5)), "right")
end
end
local ruler_left, ruler_right = x0 + label_w, x0 + w - 130 * k
local ruler_w = ruler_right - ruler_left
local seconds_w = (ruler_w - 2 * pad) / duration * ui.timeline_zoom
if ui.zoom_to_playhead or ui.timeline_zoom <= 1.001 then
ui.timeline_scroll = studio.time * seconds_w - (ruler_w - 2 * pad) * 0.5
ui.zoom_to_playhead = false
else
local px = pad + studio.time * seconds_w - ui.timeline_scroll
if studio.playing and (px < pad or px > ruler_w - pad) then ui.timeline_scroll = studio.time * seconds_w - (ruler_w - 2 * pad) * 0.5 end
end
ui.timeline_scroll = clamp(ui.timeline_scroll, 0, math.max(0, duration * seconds_w - (ruler_w - 2 * pad)))
local function x_of(t) return ruler_left + pad + t * seconds_w - ui.timeline_scroll end
local function t_of(x) return clamp((x - ruler_left - pad + ui.timeline_scroll) / seconds_w, 0, if info then info.duration else 0) end
local lanes_right = x0 + w
-- The ruler: frame numbers.
D:clip(vector.create(ruler_left, y0, 0), vector.create(ruler_right, y0 + h, 0))
local frames = math.ceil(duration * fps)
local frame_w = seconds_w / fps
local every = if frame_w > 26 * k then 1 elseif frame_w > 9 * k then 5 else 10
for i = 0, frames do
local x = x_of(i / fps)
if x >= ruler_left - 20 and x <= ruler_right + 20 then
local labelled = i % every == 0
line(x, y0 + header_h - (if labelled then 16 else 7) * k, x, y0 + header_h - 3 * k, if labelled then white else faint, 1.6 * k)
if labelled then text("bold", 16 * k, x, y0 + 26 * k, white, tostring(i), "centre") end
end
end
if has_clip then
for _, mark in {{info.takeoff, "POP"}, {info.landing, "LAND"}} do
if mark[1] then
local x = x_of(mark[1])
line(x, y0 + header_h, x, y0 + h, rgba(1, 131, 255, 80), 1.5 * k)
text("bold", 12 * k, x + 4 * k, y0 + 8 * k, blue_light, mark[2])
end
end
end
D:clip()
-- The ruler drags the playhead.
if inside(ruler_left, y0, ruler_right, y0 + header_h) then
hovered_this = true
if mouse.pressed[1] then ui.dragging_playhead = true end
end
if ui.dragging_playhead then
if mouse.down[1] and has_clip then
local t = t_of(mouse.x)
if not keys_down.alt then t = math.floor(t * fps + 0.5) / fps end
set_time(t)
set_playing(false)
else
ui.dragging_playhead = false
end
end
-- Every key of every joint, in one row: the whole animation at a glance.
local summary_y = y0 + header_h + 4 * k
rough(x0 + 8 * k, summary_y, lanes_right - 8 * k, summary_y + summary_h, rgba(30, 31, 34, 240), 83, k * 0.6)
text("heading", 15 * k, x0 + 22 * k, summary_y + 6 * k, soft, "ALL KEYS")
D:clip(vector.create(ruler_left, summary_y, 0), vector.create(ruler_right, summary_y + summary_h, 0))
for _, track in anim.tracks do
for _, key in track.keys do diamond(x_of(key.t), summary_y + summary_h * 0.5, 6 * k, if track.hidden then faint else soft) end
end
D:clip()
-- Rows, one per joint, following the chosen one.
local rows_top = summary_y + summary_h + 6 * k
local rows_h = y0 + h - rows_top
local content_h = #joints * (row_h + gap)
local chosen_row = joint_row[tostring(ui.selected)] or 1
if ui.followed_row ~= chosen_row then
ui.followed_row = chosen_row
local top = (chosen_row - 1) * (row_h + gap)
if top < ui.rows_scroll then ui.rows_scroll = top end
if top + row_h > ui.rows_scroll + rows_h then ui.rows_scroll = top + row_h - rows_h end
end
ui.rows_scroll = clamp(ui.rows_scroll, 0, math.max(0, content_h - rows_h))
D:clip(vector.create(x0, rows_top, 0), vector.create(lanes_right, y0 + h, 0))
for r, j in joints do
local y = rows_top + (r - 1) * (row_h + gap) - ui.rows_scroll
if y + row_h >= rows_top and y <= y0 + h then
local chosen = same(j.joint, ui.selected)
local track = track_of(j.joint, false)
local ax, ay, bx2, by2 = x0 + 8 * k, y, lanes_right - 8 * k, y + row_h
if chosen then
rough(ax, ay, ax + label_w - 12 * k, by2, blue, seed_of(ax, ay), k)
halftone(ax + label_w - 70 * k, ay + 6 * k, 8, 4, rgba(255, 255, 255, 46))
rough(ax + label_w - 4 * k, ay, bx2, by2, rgba(44, 50, 62, 236), seed_of(ax, ay) + 1, k)
else
rough(ax, ay, ax + label_w - 12 * k, by2, rgba(24, 24, 26, 246), seed_of(ax, ay), k)
rough(ax + label_w - 4 * k, ay, bx2, by2, if r % 2 == 0 then rgba(30, 31, 34, 220) else rgba(34, 35, 38, 220), seed_of(ax, ay) + 1, k)
end
local has_keys = track ~= nil and #track.keys > 0
text("bold", 17 * k, ax + 20 * k, y + row_h * 0.5 - 10 * k, if chosen or has_keys then white else soft, upper(j.label))
local top, bottom = math.max(ay, rows_top), math.min(by2, y0 + h)
if hit(ax, top, ax + label_w - 12 * k, bottom) then ui.selected = j.joint end
local lane_over = inside(ruler_left, top, ruler_right, bottom)
local lane_pressed = lane_over and mouse.pressed[1]
if lane_over then hovered_this = true end
local on_key = false
if track then
D:clip(vector.create(ruler_left, rows_top, 0), vector.create(ruler_right, y0 + h, 0))
for _, key in track.keys do
local dragged_with = ui.key_joint ~= nil and is_picked(j.joint, key.t) and is_picked(ui.key_joint, ui.key_from)
local shown = key.t
if ui.key_joint ~= nil and same(ui.key_joint, j.joint) and math.abs(key.t - ui.key_from) < 1e-4 then shown = ui.key_to
elseif dragged_with then shown = key.t + (ui.key_to - ui.key_from) end
local cx, cy = x_of(shown), y + row_h * 0.5
local current = math.abs(key.t - studio.time) < 0.5 / fps
local close_to = lane_over and math.abs(mouse.x - cx) < 9 * k
local in_pick = is_picked(j.joint, key.t)
if (current and chosen) or in_pick then diamond(cx, cy, 13 * k, if in_pick then blue else blue_dim) end
diamond(cx, cy, (if close_to then 10.5 else 9) * k, if track.hidden then faint elseif in_pick or (current and chosen) then blue_light else white)
if key.e == "step" then line(cx, cy - 4 * k, cx, cy + 4 * k, rgba(40, 40, 40), 2 * k) end
if close_to and lane_pressed and not track.locked and not on_key then
on_key = true
if keys_down.shift then
local found = nil
for n, p in ui.picked do
if same(p[1], j.joint) and math.abs(p[2] - key.t) < 1e-4 then found = n end
end
if found then table.remove(ui.picked, found) else table.insert(ui.picked, {j.joint, key.t}) end
else
if not in_pick then ui.picked = {} end
ui.key_joint, ui.key_from, ui.key_to, ui.key_moved = j.joint, key.t, key.t, false
ui.selected = j.joint
set_time(key.t)
end
end
end
D:clip()
end
if lane_pressed and not on_key and has_clip then
ui.picked = {}
ui.selected = j.joint
set_time(math.floor(t_of(mouse.x) * fps + 0.5) / fps)
set_playing(false)
end
end
end
D:clip()
if ui.key_joint ~= nil then
if mouse.down[1] then
local to = math.floor(t_of(mouse.x) * fps + 0.5) / fps
if math.abs(to - ui.key_to) > 1e-4 then ui.key_moved = true end
ui.key_to = to
else
if ui.key_moved and math.abs(ui.key_to - ui.key_from) > 1e-4 then
if is_picked(ui.key_joint, ui.key_from) and #ui.picked > 1 then move_picked(ui.key_to - ui.key_from)
else
begin_change()
move_key(ui.key_joint, ui.key_from, ui.key_to)
end
set_time(ui.key_to)
end
ui.key_joint = nil
end
end
if has_clip then
local x = x_of(studio.time)
if x >= ruler_left and x <= ruler_right then
line(x, y0 + 4 * k, x, y0 + h, blue, 2.4 * k)
poly({{x - 8 * k, y0 + 2 * k}, {x + 8 * k, y0 + 2 * k}, {x, y0 + 12 * k}}, blue)
end
end
-- Wheel: scroll the rows; Ctrl + wheel zooms; Shift + wheel scrolls the time.
if inside(x0, y0, x0 + w, y0 + h) and mouse.wheel ~= 0 then
if keys_down.ctrl then zoom_timeline(if mouse.wheel > 0 then 1.25 else 1 / 1.25)
elseif keys_down.shift then ui.timeline_scroll -= mouse.wheel * 60 * k
else ui.rows_scroll -= mouse.wheel * (row_h + gap) end
end
end
-- The skater: dots on the joints that can be chosen, the glowing ring on the chosen one, and the drag.
local function viewport(vx0, vy0, vx1, vy1, t)
local k = K
local over = inside(vx0, vy0, vx1, vy1) and not ui.hovered_any
local points = {}
local hovered, best = nil, 22 * k
for _, j in joints do
local w = joint_world(j.joint)
if w then
local on = D:world_to_screen(vec(w.position))
if on then
points[tostring(j.joint)] = on
if not ui.pad_mode and over then
local dist = math.sqrt((on.x - mouse.x) ^ 2 + (on.y - mouse.y) ^ 2)
if dist < best then best, hovered = dist, j.joint end
end
end
end
end
for key, on in points do
if key ~= tostring(ui.selected) then
local hot = hovered ~= nil and tostring(hovered) == key
circle(on.x, on.y, (if hot then 6 else 3.5) * k, if hot then blue_light else rgba(255, 255, 255, 90), true)
end
end
local sel_at = points[tostring(ui.selected)]
if sel_at then
-- The chosen joint: a small blue ring around it, softly glowing.
local r = 11 * k
local pulse = 0.8 + 0.2 * math.sin(t * 3.2)
for i = 3, 1, -1 do circle(sel_at.x, sel_at.y, r + i * 2.2 * k, rgba(1, 131, 255, math.floor(40 * (4 - i) * pulse)), false, 2.4 * k) end
circle(sel_at.x, sel_at.y, r, rgba(60, 165, 255), false, 2.6 * k)
circle(sel_at.x, sel_at.y, 3 * k, rgba(230, 240, 255), true)
if ui.pad_mode then text("bold", 13 * k, sel_at.x + 30 * k, sel_at.y - 8 * k, blue_light, upper(label_of(ui.selected))) end
end
if hovered ~= nil and not same(hovered, ui.selected) then
local on = points[tostring(hovered)]
if on then text("bold", 13 * k, on.x + 10 * k, on.y - 22 * k, white, upper(label_of(hovered))) end
end
if sel_at then
local r, o = offset_of(ui.selected)
local e = to_euler(r)
local x, y = vx0 + 24 * k, vy1 - 92 * k
if moves(ui.selected) then
text("heading", 15 * k, x, y - 44 * k, rgba(255, 255, 255, 110), "POSITION")
text("body", 13 * k, x, y - 24 * k, rgba(255, 255, 255, 90), string.format("X: %.2f Y: %.2f Z: %.2f", o[1], o[2], o[3]))
end
text("heading", 15 * k, x, y, rgba(255, 255, 255, 110), "ROTATION")
text("body", 13 * k, x, y + 20 * k, rgba(255, 255, 255, 90), string.format("X: %.0f Y: %.0f Z: %.0f", e[1], e[2], e[3]))
local axis = if ui.axis == 0 then "X" elseif ui.axis == 1 then "Y" elseif ui.axis == 2 then "Z" else "FREE"
text("body", 12 * k, x, y + 42 * k, rgba(120, 190, 255, 150),
string.format("%s axis %s (%s)", if ui.move_mode then "MOVE" else "TURN", axis, if ui.local_axes then "local" else "world"))
end
if not over and not ui.dragging_gizmo and not ui.orbiting and not ui.panning then return end
if over and mouse.pressed[1] then
if hovered ~= nil and not same(hovered, ui.selected) then ui.selected = hovered
elseif sel_at and anim.source then
local track = track_of(ui.selected, false)
if not (track and track.locked) and begin_turn() then
ui.dragging_gizmo = true
ui.drag_start = {mouse.x, mouse.y}
end
end
end
if ui.dragging_gizmo then
if not mouse.down[1] then ui.dragging_gizmo = false
else apply_turn((mouse.x - ui.drag_start[1]) / k, (mouse.y - ui.drag_start[2]) / k, ui.move_mode) end
end
if over and mouse.pressed[2] then ui.orbiting = true end
if over and mouse.pressed[3] then ui.panning = true end
if ui.orbiting then
if not mouse.down[2] then ui.orbiting = false
else
orbit.yaw -= mouse.dx * 0.006
orbit.pitch += mouse.dy * 0.005
end
end
if ui.panning then
if not mouse.down[3] then ui.panning = false
elseif camera then
local metres = orbit.distance / (900 * k)
local right, up = v3(camera.right), v3(camera.up)
for i = 1, 3 do orbit.pan[i] += -right[i] * mouse.dx * metres + up[i] * mouse.dy * metres end
end
end
if over and mouse.wheel ~= 0 then orbit.distance *= if mouse.wheel > 0 then 0.9 else 1.1 end
end
local function keyboard()
if ui.editing then return end
local ctrl = keys_down.ctrl
local any = false
local function pressed(name, repeat_too)
local p = if repeat_too then keys_repeat[name] else keys_pressed[name]
any = any or p
return p
end
if pressed("space") then set_playing(not studio.playing) end
if pressed("left", true) then step_frame(-1) end
if pressed("right", true) then step_frame(1) end
if pressed("up", true) then move_row(-1) end
if pressed("down", true) then move_row(1) end
if pressed("enter") then activate_row() end
if not ctrl and pressed("q") then switch_tab(-1) end
if not ctrl and pressed("e") then switch_tab(1) end
if pressed("home") then set_time(0) end
if pressed("end") then set_time(if info then info.duration else 0) end
if ctrl and pressed("z", true) then undo() end
if ctrl and pressed("y", true) then redo() end
if ctrl and pressed("c") then copy_pose() end
if ctrl and pressed("v") then paste_pose() end
if ctrl and pressed("s") then save_now() end
if ctrl and pressed("a") then pick_all() end
if pressed("plus") or pressed("numplus") then zoom_timeline(1.5) end
if pressed("minus") or pressed("numminus") then zoom_timeline(1 / 1.5) end
if not ctrl and pressed("f") then toggle_focus() end
if not ctrl and pressed("k") and anim.source then key_joint() end
if pressed("delete") then
if ui.tab == 2 then delete_row() elseif #ui.picked > 0 then delete_picked() else delete_key() end
end
if not ctrl and pressed("x") then ui.axis = if ui.axis == 0 then -1 else 0 end
if not ctrl and pressed("y") then ui.axis = if ui.axis == 1 then -1 else 1 end
if not ctrl and pressed("z") then ui.axis = if ui.axis == 2 then -1 else 2 end
if not ctrl and pressed("r") then ui.move_mode = false end
if not ctrl and pressed("g") then ui.move_mode = true end
if not ctrl and pressed("l") then ui.local_axes = not ui.local_axes end
if not ctrl and pressed("tab") then ui.menu_hidden = not ui.menu_hidden end
if pressed("one") then orbit.yaw, orbit.pitch = 0, 0.1
elseif pressed("two") then orbit.yaw, orbit.pitch = math.pi, 0.1
elseif pressed("three") then orbit.yaw, orbit.pitch = math.pi / 2, 0.1
elseif pressed("four") then orbit.yaw, orbit.pitch = -math.pi / 2, 0.1
elseif pressed("five") then orbit.yaw, orbit.pitch = 0.6, 0.18
elseif pressed("zero") then orbit.yaw, orbit.pitch, orbit.distance, orbit.pan, orbit.focus = 0.6, 0.18, 3.4, {0, 0, 0}, nil end
if any or math.sqrt((mouse.x - ui.last_mouse[1]) ^ 2 + (mouse.y - ui.last_mouse[2]) ^ 2) > 4 or mouse.pressed[1] then ui.pad_mode = false end
ui.last_mouse = {mouse.x, mouse.y}
end
-- An action button like the game's "P UN-PIN": its key or button, then what it does.
local function action_button(x, y, w, h, g, key, what, fill_colour, fill_hot, ink)
local k = K
local clicked, over = hit(x, y, x + w, y + h)
rough(x, y, x + w, y + h, if over then fill_hot else fill_colour, seed_of(x, y), k)
local cx = x + 18 * k
if ui.pad_mode and g then
cx += glyph(cx, y + h * 0.5 - 11 * k, g) + 12 * k
else
local t = text_size("bold", 14 * k, key)
local kw = math.max(26 * k, t.x + 12 * k)
outline(cx, y + h * 0.5 - 13 * k, cx + kw, y + h * 0.5 + 13 * k, ink, 2.2 * k)
text("bold", 14 * k, cx + kw * 0.5, y + h * 0.5 - 8 * k, ink, key, "centre")
cx += kw + 12 * k
end
text("heading", 22 * k, cx, y + h * 0.5 - 13 * k, ink, what)
return clicked
end
-- The lower right: KEY and SAVE, and under them the controls in a tidy two-column panel.
local function prompts(vx1, vy1)
local k = K
local move = if ui.move_mode then "Move (L3: turn)" elseif ui.axis < 0 then "Turn (L3: axis)" elseif ui.axis == 0 then "Turn X (L3)"
elseif ui.axis == 1 then "Turn Y (L3)" else "Turn Z (L3)"
local list_rows
if ui.pad_mode then
list_rows = {{"ls", "", move}, {"rs", "", if ui.stick_zoom then "Zoom / pan (R3)" else "Orbit (R3: zoom)"}, {"rt", "", "Play forward"},
{"lt", "", "Play back"}, {"dpad_ud", "", "Choose"}, {"dpad_lr", "", "One frame"}, {"y", "", if studio.playing then "Pause" else "Play"},
{"x", "", "Delete key"}, {"b", "", "Undo"}, {"view", "", "Zoom timeline"}, {"lb", "", "Tabs (RB)"}, {"l3", "", "+ R3 hold: back"}}
else
list_rows = {{nil, "LMB", "Pick / turn"}, {nil, "RMB", "Orbit"}, {nil, "MMB", "Pan"}, {nil, "G / R", if ui.move_mode then "Move" else "Turn"},
{nil, "X Y Z", "Axis"}, {nil, "UP DN", "Choose"}, {nil, "< >", "One frame"}, {nil, "SPACE", if studio.playing then "Pause" else "Play"},
{nil, "DEL", "Delete key"}, {nil, "CTRL Z", "Undo"}, {nil, "- / +", "Zoom timeline"}, {nil, "F8", "Back"}}
end
local col_w, row_h, pad = 205 * k, 30 * k, 14 * k
local rows = math.floor((#list_rows + 1) / 2)
local panel_w, panel_h = 2 * col_w + 2 * pad, rows * row_h + 2 * pad
local bx, by = vx1 - 20 * k, vy1 - 50 * k
local ax, ay = bx - panel_w, by - panel_h
rough(ax, ay, bx, by, rgba(12, 12, 14, 200), 93, k)
if inside(ax, ay, bx, by) then hovered_this = true end
for i, r in list_rows do
local x, y = ax + pad + ((i - 1) % 2) * col_w, ay + pad + math.floor((i - 1) / 2) * row_h
if r[1] then glyph(x, y, r[1]) else key_cap(x, y, r[2]) end
text("bold", 13 * k, x + 66 * k, y + 3 * k, white, r[3])
end
local sw, sh = panel_w * 0.5 - 6 * k, 48 * k
local sy = ay - 12 * k - sh
if action_button(ax, sy, sw, sh, "menu", "CTRL S", if studio.dirty then "SAVE *" else "SAVE", rgba(236, 236, 234, 250), rgba(255, 255, 255), black)
and anim.source then save_now() end
if action_button(ax + sw + 12 * k, sy, sw, sh, "a", "K", "KEY", rgba(234, 214, 96), rgba(246, 226, 120), black) and anim.source then key_joint() end
end
-- Closing with changes not saved: the game's white card asks first.
local function close_warning(dx, dy, t)
local k = K
fill(0, 0, dx, dy, rgba(0, 0, 0, 150))
local w, h = 640 * k, 260 * k
local ax, ay = (dx - w) * 0.5, (dy - h) * 0.45
local bx, by = ax + w, ay + h
rough(ax + 6 * k, ay + 6 * k, bx + 6 * k, by + 6 * k, rgba(0, 0, 0, 160), 95, k)
rough(ax, ay, bx, by, rgba(236, 236, 234, 252), 97, k)
local ink = rgba(20, 20, 22)
text("title", 40 * k, ax + 30 * k, ay + 18 * k, ink, "UNSAVED CHANGES")
text("bold", 19 * k, ax + 30 * k, ay + 84 * k, rgba(60, 60, 66),
"\"" .. title_of() .. "\" has changes that are not saved. Save them before you go back to skating?", nil, w - 60 * k)
local labels = {"SAVE", "DON'T SAVE", "STAY"}
ui.warning_choice = clamp(ui.warning_choice, 1, 3)
local bw, bh = (w - 60 * k - 2 * 12 * k) / 3, 48 * k
local y = by - 30 * k - bh
local taken = nil
for i = 1, 3 do
local x = ax + 30 * k + (i - 1) * (bw + 12 * k)
local clicked, over = hit(x, y, x + bw, y + bh)
if over and (mouse.dx ~= 0 or mouse.dy ~= 0) then ui.warning_choice = i end
local chosen = ui.warning_choice == i
rough(x, y, x + bw, y + bh, if chosen then blue else rgba(32, 32, 35, 250), seed_of(x, y), k)
if chosen then halftone(x + bw - 44 * k, y + 6 * k, 6, 5, rgba(255, 255, 255, 46)) end
text("heading", 22 * k, x + bw * 0.5, y + (bh - 26 * k) * 0.5, white, labels[i], "centre")
if clicked then taken = i end
end
if pad_pressed.left then ui.warning_choice = math.max(1, ui.warning_choice - 1) end
if pad_pressed.right then ui.warning_choice = math.min(3, ui.warning_choice + 1) end
if pad_pressed.a then taken = ui.warning_choice end
if pad_pressed.b then taken = 3 end
if not ui.editing then
if keys_pressed.left then ui.warning_choice = math.max(1, ui.warning_choice - 1) end
if keys_pressed.right then ui.warning_choice = math.min(3, ui.warning_choice + 1) end
if keys_pressed.enter or keys_pressed.space then taken = ui.warning_choice end
end
local x, py = ax + 30 * k, by + 16 * k
x += prompt(x, py, "dpad_lr", "< >", "Choose")
x += prompt(x, py, "a", "ENTER", "Select")
prompt(x, py, "b", "ESC", "Stay")
if taken then
ui.warning_choice = 1
studio.confirm_close = false
if taken == 1 then
save_now()
begin_leave()
elseif taken == 2 then
studio.dirty = false
begin_leave()
end
end
end
-- ---- the loading screen and the recording strip --------------------------------------------------------
local tips = {
"Pick a trick in TRICKS, then its pop: low, mid or high.",
"Click a dot on the skater, or choose a joint in JOINTS, then drag to turn it.",
"K keys the pose on this frame. Keys always sit on whole frames.",
"G moves the hips or the board. X, Y and Z lock an axis.",
"Save your trick and do it in game: your version plays on that trick.",
"Copy to low, mid and high pop in PLAYBACK to fit every pop.",
"On a controller: the left stick turns, RT and LT step frames, A keys.",
}
-- Over the studio opening and closing: dark grainy concrete, the reskate. wordmark with animator
-- studio on a strip of blue tape, who built it on a paper strip, a tip with a bar, and skate.'s own
-- "Loading..." box with its kickflipping board.
local function draw_loading(dx, dy, t)
if not loading then return end
local seconds = t - loading.since
local alpha = clamp(math.min(seconds / 0.15, (loading.until_ + 0.3 - t) / 0.3), 0, 1)
if alpha <= 0 then return end
local H = dy
local k = clamp(H / 1080, 0.45, 3)
local function fade(c) return with_alpha(c, alpha) end
local paper, ink = rgba(236, 233, 226), rgba(17, 18, 21)
fill(0, 0, dx, dy, fade(rgba(19, 19, 19)))
D:game_image("scratches", vector.create(0, 0, 0), vector.create(dx, dy, 0), {colour = fade(rgba(255, 255, 255, 150)), fit = "panel", edge = 0})
local clear, dark = rgba(0, 0, 0, 0), fade(rgba(0, 0, 0, 170))
D:gradient(vector.create(0, 0, 0), vector.create(dx, H * 0.25, 0), {dark, dark, clear, clear})
D:gradient(vector.create(0, H * 0.75, 0), vector.create(dx, dy, 0), {clear, clear, dark, dark})
D:gradient(vector.create(0, 0, 0), vector.create(dx * 0.15, H, 0), {dark, clear, clear, dark})
D:gradient(vector.create(dx * 0.85, 0, 0), vector.create(dx, dy, 0), {clear, dark, dark, clear})
local word_size = 0.30 * H
local ww = text_size("title_large", word_size, "reskate.")
local wx, wy = dx * 0.5 - ww.x * 0.5, H * 0.215
text("title_large", word_size, wx + 5 * k, wy + 7 * k, fade(rgba(0, 0, 0, 140)), "reskate.")
text("title_large", word_size, wx, wy, fade(paper), "reskate.")
D:game_image("scratches", vector.create(wx - 20 * k, wy, 0), vector.create(wx + ww.x + 20 * k, wy + word_size * 1.1, 0),
{colour = fade(rgba(30, 28, 25, 230)), fit = "panel", edge = 0})
local sub_size = 0.085 * H
local sw = text_size("title_large", sub_size, "animator studio")
local tape_top, tape_h = wy + word_size * 0.99, 0.098 * H
D:game_image("brush_bar", vector.create(dx * 0.5 - sw.x * 0.5 - 0.03 * H, tape_top, 0), vector.create(dx * 0.5 + sw.x * 0.5 + 0.03 * H, tape_top + tape_h, 0),
{colour = fade(blue), fit = "shape"})
text("title_large", sub_size, dx * 0.5, tape_top + (tape_h - sw.y) * 0.5 - 0.004 * H, fade(ink), "animator studio", "centre")
do
local label_size, names_size = 0.020 * H, 0.026 * H
local names = "22SH, FOURTH STUDIO, NORMS"
local lw, nw = text_size("bold", label_size, "BUILT BY"), text_size("heading", names_size, names)
local strip_w, strip_h = lw.x + nw.x + 0.06 * H, 0.06 * H
local px, py = dx * 0.5 - strip_w * 0.5, tape_top + tape_h + 0.03 * H
D:game_image("rough_tile", vector.create(px, py, 0), vector.create(px + strip_w, py + strip_h, 0), {colour = fade(paper), fit = "panel", edge = 6})
text("bold", label_size, px + 0.022 * H, py + (strip_h - lw.y) * 0.5, fade(rgba(40, 40, 44)), "BUILT BY")
text("heading", names_size, px + 0.038 * H + lw.x, py + (strip_h - nw.y) * 0.5, fade(ink), names)
end
do
local tip = tips[math.floor(seconds / 4) % #tips + 1]
local tx, ty = 0.034 * dx, H - 0.115 * H
text("heading", 0.022 * H, tx, ty, fade(blue), "TIP")
text("bold", 0.019 * H, tx, ty + 0.03 * H, fade(rgba(225, 225, 228)), tip)
local bax, bay, bbx, bby = tx, H - 0.05 * H, dx * 0.415, H - 0.04 * H
local r = (bby - bay) * 0.5
fill(bax, bay, bbx, bby, fade(rgba(255, 255, 255, 38)), r)
local f = if t >= loading.until_ then 1 else 0.92 * (1 - math.exp(-seconds / 3))
if f > 0.01 then fill(bax, bay, bax + (bbx - bax) * f, bby, fade(blue), r) end
end
do
local what = if loading.what == "leaving" then "BACK TO THE STREETS" else "SETTING UP THE STUDIO"
local box_h, box_w = 0.062 * H, 0.205 * H
local bbx, bby = dx - 0.034 * dx, H - 0.045 * H
local bax, bay = bbx - box_w, bby - box_h
local rim = 2.5 * k
D:game_image("rough_tile", vector.create(bax, bay, 0), vector.create(bbx, bby, 0), {colour = fade(rgba(205, 205, 205)), fit = "panel", edge = 8})
D:game_image("rough_tile", vector.create(bax + rim, bay + rim, 0), vector.create(bbx - rim, bby - rim, 0), {colour = fade(rgba(8, 8, 9)), fit = "panel", edge = 8})
local dots = {"Loading", "Loading.", "Loading..", "Loading..."}
local lw = text_size("bold", 0.021 * H, "Loading...")
text("bold", 0.021 * H, bax + 0.024 * H, bay + (box_h - lw.y) * 0.5, fade(white), dots[math.floor(seconds * 2.5) % 4 + 1])
D:game_image("loading_board", vector.create(bbx - box_h * 1.25 - 0.012 * H, bay + box_h * 0.1, 0), vector.create(bbx - 0.012 * H, bby - box_h * 0.1, 0),
{colour = fade(white), frame = math.floor(seconds * 30) % 27})
local ww2 = text_size("heading", 0.021 * H, what)
text("heading", 0.021 * H, bax - 0.022 * H - ww2.x, bay + (box_h - ww2.y) * 0.5, fade(white), what)
end
end
-- While a trick is being recorded (the studio steps out so you can skate): a strip across the top, and
-- Esc (Back on a controller) goes back to the studio without one.
local function draw_recording(dx, dy, t)
local H = dy
fill(0, 0, dx, 0.085 * H, rgba(12, 12, 13, 225))
fill(0, 0.085 * H - 0.004 * H, dx, 0.085 * H, blue)
local what = if recording.air then "IN THE AIR..." else "RECORD A TRICK"
text("heading", 0.026 * H, 0.034 * dx, 0.014 * H, white, what)
text("bold", 0.019 * H, 0.034 * dx, 0.048 * H, rgba(200, 200, 204), recording.status or "Skate and land the trick you want.")
local hint = "ESC / BACK: BACK TO THE STUDIO"
local hw = text_size("heading", 0.019 * H, hint)
text("heading", 0.019 * H, dx - 0.034 * dx - hw.x, 0.03 * H, white, hint)
local pulse = 0.6 + 0.4 * math.sin(t * 5)
circle(dx - 0.034 * dx - hw.x - 0.022 * H, 0.03 * H + hw.y * 0.5, 0.008 * H, rgba(235, 50, 50, math.floor(255 * pulse)), true)
end
-- ---- the frame -------------------------------------------------------------------------------------------
local last_frame = nil
reskate.on("draw", function(d)
D = d
measured = {}
local size = d:size()
local dx, dy = size.x, size.y
S = dy / 1080
local t = now()
local dt = if last_frame then math.min(t - last_frame, 0.1) else 0
last_frame = t
if recording then
draw_recording(dx, dy, t)
return
end
if not studio.open then
if loading then draw_loading(dx, dy, t) end
return
end
hovered_this = false
read_mouse(t)
read_keys(t)
local pad = read_pad(t)
-- A field being typed into takes what was typed (and its keys) first.
if ui.editing then
edit_keys(reskate.input.text(true))
if ui.editing then ui.editing.seen = false end
end
-- Laid out on a 1920 x 1080 page, scaled to fit the height and the game's menu frame (the whole width
-- up to 21:9, a 21:9 frame in the middle of anything wider).
local frame_w = math.min(dx, dy * 21 / 9)
local left = (dx - frame_w) * 0.5
local right = left + frame_w
K = clamp(math.min(dy / 1080, frame_w / 1700), 0.45, 3)
local k = K
local timeline_h = math.min(dy * 0.34, 360 * k)
local menu_x, menu_w = left + 60 * k, 520 * k
local vx0 = if ui.menu_hidden then left else menu_x + menu_w + 30 * k
local vy0, vx1, vy1 = 0, right, dy - timeline_h
view_shift = {((vx0 + vx1) * 0.5 - dx * 0.5) / dy, (dy * 0.5 - (vy0 + vy1) * 0.5) / dy}
if loading and t < loading.until_ then
draw_loading(dx, dy, t)
return
end
if not studio.confirm_close then controller(pad, t, dt) end
viewport(vx0, vy0, vx1, vy1, t)
if not ui.menu_hidden then
page_title(menu_x, 40 * k)
tab_row(menu_x, 140 * k)
local list_top, card_h = 205 * k, 190 * k
local list_bottom = dy - timeline_h - card_h - 40 * k
list(menu_x + 16 * k, list_top, menu_w - 16 * k, math.max(120 * k, list_bottom - list_top))
card(menu_x, list_bottom + 18 * k, menu_w + 10 * k, card_h)
end
timeline(left, dy - timeline_h, frame_w, timeline_h)
local status = if studio.failure ~= "" then studio.failure else studio.status
if t < ui.message_until then status = ui.message end
if not anim.source then
local first, second = "PICK A TRICK", "Choose a trick in TRICKS: it opens here to be changed."
local w1 = text_size("heading", 30 * k, first)
local bw = math.max(w1.x + 60 * k, 520 * k)
local ax, ay = left + (frame_w - bw) * 0.5, dy - timeline_h * 0.5 - 30 * k
rough(ax, ay, ax + bw, ay + 120 * k, rgba(234, 234, 232, 250), 101, k)
text("heading", 30 * k, ax + bw * 0.5, ay + 16 * k, rgba(20, 20, 22), first, "centre")
text("bold", 17 * k, ax + 30 * k, ay + 60 * k, rgba(60, 60, 66), second, nil, bw - 60 * k)
end
if status ~= "" then
local w = text_size("bold", 15 * k, status)
local ax, ay = vx0 + 24 * k, vy1 - 40 * k
rough(ax - 14 * k, ay - 7 * k, ax + w.x + 14 * k, ay + w.y + 7 * k, rgba(16, 16, 18, 230), 41, k * 0.6)
text("bold", 15 * k, ax, ay, if studio.failure ~= "" and t >= ui.message_until then danger else white, status)
end
prompts(vx1, vy1)
if studio.confirm_close then close_warning(dx, dy, t) else keyboard() end
-- A field no longer drawn (another tab, the menu hidden) keeps what was typed into it.
if ui.editing and not ui.editing.seen then edit_stop(true) end
if loading then draw_loading(dx, dy, t) end -- fading out
ui.hovered_any = hovered_this
end)
-- ---- every tick: the camera, the preview, a recording -----------------------------------------------------
reskate.on("tick", function()
local t = now()
if stale_recording then
local clip = reskate.animation.recorded()
if clip then
reskate.animation.forget(clip)
stale_recording = false
end
end
if recording then
local trick = reskate.animation.trick()
if trick then
recording.air = true
if not recording.trick then recording.trick = trick end
end
local pad = reskate.input.gamepad()
if (reskate.input.key(0x1B) and reskate.input.focus()) or (pad and pad.buttons.back) then
cancel_recording()
open_studio()
return
end
local clip = reskate.animation.recorded()
if clip then recording_done(clip) end
return
end
-- The walk to the room: the loading screen stays up while it goes on, then 3 s more while the board
-- settles there; if it fails, the studio opens where the skater is.
if room_walk then
local state, why = reskate.room.state()
if state == "open" then
room_walk, in_room = false, true
eased, face_skater = nil, true
reskate.hud.hide(true)
if loading then loading.until_ = t + 3.8 end
elseif state == "failed" or state == "idle" then
room_walk = false
reskate.hud.hide(true)
studio.status = (why or "The character room did not open.") .. " The studio opens where you are."
if loading then loading.until_ = t + 0.3 end
elseif loading then
loading.until_ = math.max(loading.until_, t + 0.5)
end
end
if loading and loading.close and t >= loading.until_ then
if reskate.room.state() == "leaving" then
loading.until_ = t + 0.3 -- still walking back
else
loading = nil
finish_close()
return
end
end
if loading and t >= loading.until_ + 0.3 then loading = nil end
if not studio.open then return end
update_camera()
if studio.playing then
local at = reskate.animation.time()
if at then studio.time = at end
end
if preview_due and reskate.time.now() - last_bake > 350 then bake() end
show_pending()
end)
reskate.on("screen_closed", function(screen)
if screen ~= title or not studio.open then return end
-- Esc: lets go of chosen keys first, asks first with unsaved changes, and the close card's Esc stays.
if ui.editing then
edit_stop(false)
reskate.ui.open(title)
return
end
if #ui.picked > 0 then
ui.picked = {}
reskate.ui.open(title)
return
end
if studio.confirm_close then
studio.confirm_close = false
reskate.ui.open(title)
return
end
if studio.dirty and anim.source then
studio.confirm_close = true
reskate.ui.open(title)
return
end
finish_close()
end)
-- ---- at start: what plays in game, how to open it ----------------------------------------------------------
local in_game = apply_in_game()
if in_game > 0 then reskate.log(string.format("%d saved animation(s) play in game", in_game)) end
reskate.ui.screen(title, function() end, {panel = false})
reskate.input.bind("studio", "F8")
reskate.input.bind("studio pad", "Pad.L3+Pad.R3")
reskate.on("action", function(name)
if name ~= "studio" and name ~= "studio pad" then return end
if studio.open then
request_close()
return
end
open_studio()
if name == "studio pad" then
ui.pad_mode = true
ui.combo_armed = false
ui.l3_combo = true
end
end)
-- ---- its own checks (studio test): the editing, the key maths against the baked clip, every screen ---------
local test_frames = nil -- the screens still to draw: {tab, pad_mode, confirm_close, menu_hidden}
local function self_test()
local passed, failed = 0, 0
local function check(ok, what)
if ok then passed += 1 else
failed += 1
reskate.console.print("studio test: FAIL " .. what)
end
end
if not anim.source then
reskate.console.print("studio test: record a trick first (studio record)")
return
end
-- What is being edited stays as it is: its keys, names and clip are put back after.
local tracks, name, trick, pop, source, source_info = copy_tracks(anim.tracks), anim.name, anim.trick, anim.pop, anim.source, info
anim.tracks, undo_stack, redo_stack = {}, {}, {}
ui.selected = 7
set_time(0.21)
key_joint()
check(#track_of(7, false).keys == 1, "K keys the joint on this frame")
check(math.abs(track_of(7, false).keys[1].t - 0.2) < 1e-6, "on a whole frame")
camera = camera or {right = vector.create(-1, 0, 0), up = vector.create(0, 1, 0)}
check(begin_turn(), "a turn begins on the hips")
apply_turn(30, 0, false)
local r = track_of(7, false).keys[1].r
check(math.abs(r[4]) < 0.9999, "a drag turns it")
ui.axis = 1
apply_turn(20, 0, false)
check(math.abs(to_euler(track_of(7, false).keys[1].r)[2]) > 1, "about Y with the axis locked")
ui.axis = -1
apply_turn(10, 10, true)
local o = track_of(7, false).keys[1].o
check(math.abs(o[1]) + math.abs(o[2]) + math.abs(o[3]) > 1e-4, "G moves the hips")
set_time(0.5)
key_joint()
step_frame(-3)
check(math.abs(studio.time - 0.4) < 1e-4, "frames step")
jump_key(-1)
check(math.abs(studio.time - 0.2) < 1e-4, "to the previous key")
jump_key(1)
check(math.abs(studio.time - 0.5) < 1e-4, "and the next")
pick_all()
check(#ui.picked == 2, "every key chosen")
move_picked(-1 / fps)
check(math.abs(track_of(7, false).keys[1].t - (0.2 - 1 / fps)) < 1e-4 and math.abs(track_of(7, false).keys[2].t - (0.5 - 1 / fps)) < 1e-4,
"the chosen keys move together")
delete_picked()
check(#track_of(7, false).keys == 0, "and are taken off")
undo()
check(#track_of(7, false).keys == 2, "undo brings them back")
redo()
check(#track_of(7, false).keys == 0, "redo takes them off again")
undo()
-- The sliders' maths is the baked clip's: the hips between two keys, at the clip's own frames (between
-- them the clip blends its frames).
anim.tracks = {}
set_time(0)
set_offset(7, from_euler({0, 0, 0}), {0, 0, 0})
set_time(0.5)
set_offset(7, from_euler({20, 60, -30}), {0, 0.3, 0})
local baked_ok, clip = pcall(reskate.animation.edit, anim.source, edit_tracks(anim.tracks), {name = "test"})
check(baked_ok, "the keys bake: " .. tostring(clip))
if baked_ok then
-- A recording has a frame every 60th of a second or so: anywhere is near one. A short clip, only its own.
for _, at in (if info.frames > 10 then {0.1, 0.25, 0.4} else {0.25}) do
studio.time = at
local lr, lo = evaluate(track_of(7, false), at)
local base = reskate.animation.sample(anim.source, 7, at)
local native = reskate.animation.sample(clip, 7, at)
local want = qnorm(qmul(base.rotation, lr))
local dot = math.abs(want[1] * native.rotation[1] + want[2] * native.rotation[2] + want[3] * native.rotation[3] + want[4] * native.rotation[4])
check(dot > 0.9999, string.format("the turn at %.2f s is the baked clip's", at))
check(math.abs(native.position.y - base.position.y - lo[2]) < 1e-3, string.format("so is the move at %.2f s", at))
end
reskate.animation.forget(clip)
end
set_time(0.25)
copy_pose()
set_time(0)
paste_pose()
check(#track_of(7, false).keys == 2, "a pose pasted keys its joints")
ui.tab = 4
set_time(0.5)
for _, row in rows_of(4) do
if row.label == "Blend into the next key" then row.act() end
end
check(key_here(7).e == "linear", "the ease steps on")
set_track_flags(7, true, true)
check(#edit_tracks(anim.tracks) == 0, "a hidden joint's keys are left out")
set_offset(7, {0, 0, 0, 1}, {0, 0, 0})
check(#track_of(7, false).keys == 2, "a locked joint keeps its keys")
set_track_flags(7, false, false)
anim.trick = "kickflip"
check(save("Studio Test") == "Saved Studio Test.", "it saves")
anim.tracks = {}
anim.source = nil -- opening another must not let the edited clip go
check(load("Studio Test") == "Opened Studio Test.", "and opens again")
check(#track_of(7, false).keys == 2, "with its keys")
check(copy_to_pops() == "Saved a low, a mid and a high pop copy.", "a copy for each pop")
for _, file in {"Studio Test", "Studio Test low", "Studio Test mid", "Studio Test high"} do remove(file) end
library_at = -1e9
check(not table.find(library(), "Studio Test"), "taken out of the library")
anim.tracks, anim.name, anim.trick, anim.pop, anim.source, info = tracks, name, trick, pop, source, source_info
baked, baked_clip = {}, nil
undo_stack, redo_stack, ui.picked = {}, {}, {}
changed_all()
-- Typing into a field: all of it chosen at first, then a caret that moves, a length it stops at,
-- characters of more than a byte, Enter keeping it and Esc not.
do
local kept = nil
local function press(name, value)
keys_repeat[name], keys_pressed[name] = value, value
end
edit_start("test", "Kick", 6, function(v) kept = v end)
check(ui.editing.all, "a field starts with all of it chosen")
edit_keys("Heel")
check(ui.editing.text == "Heel" and ui.editing.caret == 4, "what is typed replaces it")
press("backspace", true)
edit_keys("")
press("backspace", false)
check(ui.editing.text == "Hee", "Backspace takes one off")
press("left", true)
edit_keys("")
press("left", false)
edit_keys("l")
check(ui.editing.text == "Hele" and ui.editing.caret == 3, "the caret moves and typing goes in at it")
edit_keys("\u{e9}xyz")
check(ui.editing.text == "Hel\u{e9}xe" and length_of(ui.editing.text) == 6, "up to its length, a two-byte letter as one")
press("enter", true)
edit_keys("")
press("enter", false)
check(kept == "Hel\u{e9}xe" and ui.editing == nil, "Enter keeps it")
edit_start("test", "Kick", 6, function(v) kept = v end)
edit_keys("Grab")
press("escape", true)
edit_keys("")
press("escape", false)
check(kept == "Hel\u{e9}xe" and ui.editing == nil, "Esc does not")
end
-- Every screen drawn: each tab with the keyboard's prompts and the pad's, the menu hidden, the close card.
test_frames = {}
for tab = 1, 5 do
table.insert(test_frames, {tab, false, false, false})
table.insert(test_frames, {tab, true, false, false})
end
table.insert(test_frames, {3, false, false, true})
table.insert(test_frames, {4, false, true, false})
reskate.console.print(string.format("studio test: %d passed, %d failed", passed, failed))
end
reskate.on("draw", function()
if not test_frames or not studio.open then return end
if loading and now() < loading.until_ then return end
local f = table.remove(test_frames, 1)
if not f then
test_frames = nil
ui.tab, ui.pad_mode, studio.confirm_close, ui.menu_hidden = 1, false, false, false
reskate.console.print("studio test: every screen drawn")
return
end
ui.tab, ui.pad_mode, studio.confirm_close, ui.menu_hidden = f[1], f[2], f[3], f[4]
ui.selected = if f[1] == 3 then "board" else 7
end)
reskate.console.command("studio", "Animator Studio: open, close, record, save <name>, ingame on|off, list, status, test", function(args)
local word = args[1] or "open"
if word == "test" then
local ok, why = pcall(self_test)
if not ok then reskate.console.print("studio test: FAIL " .. tostring(why)) end
elseif word == "open" then open_studio()
elseif word == "close" then finish_close()
elseif word == "record" then record_next_trick()
elseif word == "save" then
local name = table.concat(args, " ", 2)
reskate.console.print("studio: " .. save(if name ~= "" then name else nil))
elseif word == "ingame" then
settings.in_game = args[2] == "on"
save_settings()
apply_in_game()
reskate.console.print("studio: custom tricks in game " .. (if settings.in_game then "on" else "off"))
elseif word == "list" then reskate.console.print("studio: " .. table.concat(library(), ", "))
elseif word == "status" then
reskate.console.print(string.format("studio: %s; %s; %d saved; %d game tricks", if studio.open then "open" else "closed",
if anim.source then (if anim.trick ~= "" then anim.trick else "a trick") .. string.format(", %.2f s", info.duration) else "nothing recorded",
#library(), #catalog()))
else reskate.console.print("studio: say open, close, record, save <name>, ingame on|off, list or status") end
end)
reskate.menu.page(title, function(page)
page:card("ANIMATOR STUDIO", "Change the game's trick animations")
page:note("F8 (or L3 + R3) opens the studio. Record a trick, key the joints on the timeline, save it, and doing that trick plays your version.")
if page:button("open", "Open the studio", true) then open_studio() end
page:info("Saved", tostring(#library()))
page:info("Game tricks", if #catalog() > 0 then tostring(#catalog()) .. " from the game's data" else "none yet: " .. game_tricks_why)
end)
reskate.on("unload", function() if studio.open then finish_close() end end)