-- 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/.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 , 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 , 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)