623 lines
24 KiB
Text
623 lines
24 KiB
Text
--[[
|
|
*
|
|
* Singe 3
|
|
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
|
|
*
|
|
* This program is free software; you can redistribute it and/or
|
|
* modify it under the terms of the GNU General Public License
|
|
* as published by the Free Software Foundation; either version 3
|
|
* of the License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program; if not, write to the Free Software
|
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
|
|
* 02110-1301, USA.
|
|
*
|
|
*
|
|
--]]
|
|
|
|
|
|
|
|
-- The menu's backdrop: the intro and the grid the menu sits on, as one scene.
|
|
--
|
|
-- A charge goes off, the Singe dragon and name fly out of it toward the camera, the tagline fades
|
|
-- in underneath, the dragon rears and breathes flame -- and then the grid comes up out of the dark
|
|
-- beneath the logo while the smoke is still clearing, the logo lifts away, and what is left is the
|
|
-- backdrop the menu sits on. One scene from the first frame to the last: no cut, and nothing
|
|
-- loads at the join.
|
|
--
|
|
-- It lives here rather than inside Singe/MenuDocument.singe because two things draw it: the menu,
|
|
-- and util/renderMenuVideo.py, which records it so a machine with no GPU can play what a machine
|
|
-- with one draws. A second copy would be a second thing to keep in step, and the recording would
|
|
-- quietly stop matching the menu.
|
|
--
|
|
-- Needs the GPU device the 3D scene uses; Singe/MenuOverlay.singe is the path for a machine
|
|
-- without one.
|
|
|
|
-- The backdrop: the intro and the grid the menu sits on, as one scene. Every number here came
|
|
-- from testScripts/menuIntro.singe, which is where the pacing was worked out; changing one is
|
|
-- best done there first, where a contact sheet of the whole eight seconds is one command away.
|
|
local BLAST_Z = -30.0 -- Where the charge goes off.
|
|
local BLAST_Y = 2.5 -- and how high, which is where the logo comes from.
|
|
local REST_Z = -8.0 -- Where the logo ends up.
|
|
local CAM_Z = 2.0
|
|
local FLY_START = 0.30 -- Seconds: the logo leaves the blast.
|
|
local FLY_END = 1.90 -- and has settled.
|
|
local TAG_START = 2.10 -- The tagline begins to appear.
|
|
local TAG_END = 3.00
|
|
local SPINS = 2.0 -- Turns the logo makes on the way out.
|
|
local IDLE_TURN = 7.0 -- Degrees it drifts either side once it has settled.
|
|
local IDLE_RATE = 0.9 -- Radians a second of that drift.
|
|
local GRID_UP = 4.30 -- Seconds: the grid starts to come up out of the dark.
|
|
local GRID_LIT = 5.60 -- and is fully lit.
|
|
local LIFT_FROM = 5.90 -- The logo starts to leave.
|
|
local LIFT_TO = 7.30 -- and is gone; the menu has the screen from here.
|
|
local LOOP_AT = 9.90 -- Where the recording of all this begins its looping section, and
|
|
-- where Singe/menuIntro.flac, which Singe/Menu.singe plays over
|
|
-- either renderer, has finished fading out. The menu has had the
|
|
-- screen since LIFT_TO; these last seconds are the grid running on
|
|
-- under it while the music leaves, which is why this is not simply
|
|
-- LIFT_TO. It is a whole number of frames -- and of keyframes -- at
|
|
-- the step util/renderMenuVideo.py records at, which checks that
|
|
-- rather than taking it on trust.
|
|
local LIFT_RISE = 13.0 -- World units it climbs on the way out.
|
|
|
|
-- The backdrop, which is the grid the menu sits on. Same numbers as testScripts/gridBackground.
|
|
local CELL = 6.0
|
|
local LINE_W = 0.12
|
|
local CROSS_W = 0.18
|
|
local SPREAD = 16.0
|
|
local HALF_X = 12
|
|
local DEPTH = 14
|
|
-- The grid repeats itself every GRID_PERIOD, because it is slid by one cell and wrapped. The
|
|
-- period is the constant and the speed follows from it, rather than the other way round, so that
|
|
-- a recording of the loop can be a whole number of frames: at 6 units and 9 a second the period
|
|
-- was 2/3 of a second, which is not a whole number of frames at any integer step, and the recorded
|
|
-- loop could never quite meet itself.
|
|
local GRID_PERIOD = 0.66
|
|
local GRID_SPEED = CELL / GRID_PERIOD
|
|
local FOG_R = 8
|
|
local FOG_G = 3
|
|
local FOG_B = 20
|
|
local FOG_NEAR = 12
|
|
local FOG_FAR = 72
|
|
local SUN_R = 10.5
|
|
local SUN_Y = 7.6
|
|
local SUN_Z = -34.0
|
|
local SUN_SLICES = 6
|
|
local SUN_SEGMENTS = 72
|
|
local HORIZ_W = 70.0
|
|
local HORIZ_H = 0.5
|
|
local HORIZ_Z = -38.0
|
|
local HORIZ_D = 2.2
|
|
local CAM_Y = 2.7 -- Eye height, shared by the logo and the grid: the backdrop's
|
|
-- perspective is tuned to it, and at the intro's old 1.2 the grid came
|
|
-- up flattened with its nearest line a slab across the bottom.
|
|
local LOGO_Y = 0.15 -- Where the logo settles. It is the dragon that is centred on the
|
|
-- sun, not the pair of them: this puts the dragon's middle on the
|
|
-- sun's middle, which leaves the name below it crossing the horizon
|
|
-- rather than sitting across the sun's face and hiding it. Measured
|
|
-- rather than judged by eye -- a still with the dragon hidden gives
|
|
-- the sun's disc, one with it shown gives the dragon's -- so it is
|
|
-- worth re-measuring if either model changes.
|
|
|
|
-- The dragon carries no animation, but util/dragonModel.py builds it out of named parts -- head,
|
|
-- neck, wingL, wingR and the rest -- and every one of them is its own node. They all sit at the
|
|
-- model's origin though, so turning a wing node would swing the wing round the dragon's middle
|
|
-- rather than its shoulder. The joints are baked into the model now; coordinates below are the
|
|
-- model's own: it faces +X, +Y is up, and the wings reach out along +/-Z.
|
|
local MOUTH = { 3.55, 5.60, 0.00 } -- Where the flame leaves, in model coordinates.
|
|
local HEAD_AT = { 0.62, 5.69, 0.00 } -- The head's baked joint; see assets/DragonPivots.json.
|
|
local FLAP_MAX = 34.0 -- Degrees a wing rises and falls.
|
|
local FLAP_FAST = 13.0 -- Radians a second while it is flying out of the blast.
|
|
local FLAP_SLOW = 3.4 -- and once it has settled.
|
|
local REAR_BACK = 3.05 -- Seconds: the head starts to rear.
|
|
local REAR_TOP = 3.40 -- fully back.
|
|
local REAR_STRIKE = 3.58 -- and thrown forward.
|
|
local BREATH_START = 3.46
|
|
local BREATH_END = 4.35
|
|
local REAR_DONE = 4.90 -- Back to where it started.
|
|
local REAR_ANGLE = 34.0 -- Degrees the head tips back.
|
|
local STRIKE_ANGLE = -14.0 -- and forward through the strike.
|
|
local DRAGON_YAW = -32.0 -- The dragon alone is turned toward the camera, so the wingbeat is
|
|
-- seen three quarters on rather than edge on; the name stays square
|
|
-- to the camera because it has to be read.
|
|
local TAGLINE = "SINGE Is Not a Game Emulator"
|
|
|
|
|
|
local backdropStarted = 0 -- singeGetTicks() when the backdrop's clock began.
|
|
local backdropTagX = 0
|
|
local backdropTagY = 0
|
|
local blown = false -- Whether the charge has gone off.
|
|
local backdropFont = nil
|
|
|
|
-- ===== The backdrop ==========================================================================
|
|
--
|
|
-- The intro and the grid are one scene: the blast, the logo out of it, the tagline, and then the
|
|
-- grid rising out of the dark beneath the logo while the smoke is still clearing, the logo lifting
|
|
-- away, and the backdrop left running under the menu. There is no cut and nothing loads at the
|
|
-- join, which is the whole point of doing it this way rather than playing a video and switching.
|
|
|
|
-- The scene itself. Opaque black to begin with: the intro happens in the dark, and the disc
|
|
-- behind it is stopped rather than shown through, so this is the whole picture from the first
|
|
-- frame until the grid comes up and the background goes with it.
|
|
sceneEnable(true)
|
|
sceneSetBackground(0, 0, 0, 255)
|
|
sceneSetAmbient(14, 12, 16)
|
|
sceneSetBloom(1.2, 0.22)
|
|
sceneSetTonemap(TONEMAP_ACES)
|
|
|
|
|
|
-- The dragon's parts carry their own joints now: util/objToGlb.py bakes them from
|
|
-- assets/DragonPivots.json, so wingL turns at the shoulder and head at the top of the neck without
|
|
-- anything being rigged here. This used to build them at run time from bounding boxes, which
|
|
-- worked but left every future use of the model to re-derive the same thing.
|
|
local function joint(root, name, parent)
|
|
local part = nodeFind(name, root)
|
|
|
|
if (part ~= nil) and (parent ~= nil) then
|
|
local px, py, pz = nodeGetPosition(part)
|
|
local qx, qy, qz = nodeGetPosition(parent)
|
|
|
|
-- Both parts carry their own baked joint, measured from the model's origin. Hanging one
|
|
-- off the other makes the child's offset count from the parent's, so the two would add up
|
|
-- and the head would fly off above the neck -- which is exactly what it did.
|
|
nodeSetParent(part, parent)
|
|
nodeSetPosition(part, px - qx, py - qy, pz - qz)
|
|
end
|
|
|
|
return part
|
|
end
|
|
|
|
|
|
-- Eases in: slow to start, quick away. The logo leaves this way so the move begins as a drift.
|
|
local function easeIn(t)
|
|
return t * t * t
|
|
end
|
|
|
|
|
|
-- Eases out: fast off the mark, slow into the stop.
|
|
local function easeOut(t)
|
|
return 1 - (1 - t) * (1 - t) * (1 - t)
|
|
end
|
|
|
|
|
|
-- How far through a span the time is, 0 before it and 1 after.
|
|
local function span(t, from, to)
|
|
return math.max(math.min((t - from) / (to - from), 1), 0)
|
|
end
|
|
|
|
|
|
local blast = nodeNew()
|
|
nodeSetPosition(blast, 0, BLAST_Y, BLAST_Z)
|
|
|
|
local smoke = emitterNew(blast)
|
|
emitterSetMax(smoke, 400)
|
|
emitterSetBlend(smoke, PARTICLE_ALPHA)
|
|
emitterSetRadius(smoke, 1.6)
|
|
emitterSetSpread(smoke, 360)
|
|
emitterSetSpeed(smoke, 2.5, 9.0)
|
|
emitterSetDrag(smoke, 1.3)
|
|
emitterSetGravity(smoke, 0, 3.2, 0)
|
|
emitterSetLife(smoke, 1.2, 2.4)
|
|
emitterSetSize(smoke, 3.2, 12.0)
|
|
emitterSetSpin(smoke, -40, 40)
|
|
emitterSetColor(smoke, 64, 46, 38, 170, 10, 9, 12, 0)
|
|
|
|
local core = emitterNew(blast)
|
|
emitterSetMax(core, 500)
|
|
emitterSetBlend(core, PARTICLE_ADD)
|
|
emitterSetRadius(core, 1.1)
|
|
emitterSetSpread(core, 360)
|
|
emitterSetSpeed(core, 5.0, 20.0)
|
|
emitterSetDrag(core, 1.7)
|
|
emitterSetGravity(core, 0, 5.0, 0)
|
|
emitterSetLife(core, 0.6, 1.3)
|
|
emitterSetSize(core, 2.0, 8.2)
|
|
emitterSetColor(core, 255, 232, 150, 255, 190, 35, 0, 0)
|
|
|
|
local flash = emitterNew(blast)
|
|
emitterSetMax(flash, 20)
|
|
emitterSetBlend(flash, PARTICLE_ADD)
|
|
emitterSetRadius(flash, 0.4)
|
|
emitterSetSpread(flash, 360)
|
|
emitterSetSpeed(flash, 0, 1.5)
|
|
emitterSetLife(flash, 0.10, 0.22)
|
|
emitterSetSize(flash, 6.0, 15.0)
|
|
emitterSetColor(flash, 255, 255, 235, 255, 255, 150, 40, 0)
|
|
|
|
local sparks = emitterNew(blast)
|
|
emitterSetMax(sparks, 300)
|
|
emitterSetBlend(sparks, PARTICLE_ADD)
|
|
emitterSetRadius(sparks, 0.5)
|
|
emitterSetSpread(sparks, 360)
|
|
emitterSetSpeed(sparks, 12.0, 34.0)
|
|
emitterSetDrag(sparks, 0.25)
|
|
emitterSetGravity(sparks, 0, -15.0, 0)
|
|
emitterSetLife(sparks, 0.5, 1.5)
|
|
emitterSetSize(sparks, 0.30, 0.05)
|
|
emitterSetSpin(sparks, -200, 200)
|
|
emitterSetTrail(sparks, 8, 0.10)
|
|
emitterSetColor(sparks, 255, 226, 160, 255, 255, 80, 15, 0)
|
|
|
|
-- The blast's own light, so the logo is lit by the fire it comes out of.
|
|
local fireLight = lightNew(LIGHT_POINT)
|
|
nodeSetParent(fireLight, blast)
|
|
lightSetColor(fireLight, 255, 150, 60)
|
|
lightSetRange(fireLight, 60)
|
|
lightSetIntensity(fireLight, 0)
|
|
|
|
-- A key light for the logo once the fire has died, from over the camera's shoulder.
|
|
local key = lightNew(LIGHT_DIRECTIONAL)
|
|
nodeSetPosition(key, 4, 6, 10)
|
|
nodeLookAt(key, 0, 0, REST_Z)
|
|
lightSetIntensity(key, 1.15)
|
|
|
|
-- A magenta rim from behind and to the left, the colour the grid behind the menu is drawn in, so
|
|
-- the logo is not a flat white cut-out and the intro and the menu share a palette.
|
|
local rim = lightNew(LIGHT_DIRECTIONAL)
|
|
nodeSetPosition(rim, -7, 3, REST_Z - 9)
|
|
nodeLookAt(rim, 0, 1.5, REST_Z)
|
|
lightSetColor(rim, 255, 70, 200)
|
|
lightSetIntensity(rim, 2.6)
|
|
|
|
-- The dragon over the name, assembled the way testScripts/scene30.singe does it.
|
|
local logo = nodeNew()
|
|
local text = modelInstance(modelLoad("Singe/SingeText.glb"))
|
|
nodeSetParent(text, logo)
|
|
local dragon = modelInstance(modelLoad("Singe/DragonModel.glb"))
|
|
nodeSetParent(dragon, logo)
|
|
nodeSetPosition(dragon, 0, 4.85, 0)
|
|
nodeSetRotation(dragon, 0, DRAGON_YAW, 0)
|
|
nodeSetScale(dragon, 0.9)
|
|
nodeSetScale(logo, 0.5)
|
|
nodeSetVisible(logo, false)
|
|
|
|
-- The rig. The head hangs off the neck so that rearing the neck carries the head with it.
|
|
local wingLeft = joint(dragon, "wingL")
|
|
local wingRite = joint(dragon, "wingR")
|
|
local neck = joint(dragon, "neck")
|
|
-- The head hangs off the neck so that rearing the neck carries the head with it.
|
|
local head = joint(dragon, "head", neck)
|
|
|
|
-- The flame, on a node at the dragon's mouth so it follows the head round.
|
|
local jet = nodeNew()
|
|
|
|
nodeSetParent(jet, head)
|
|
-- Relative to the head's own joint, which is where its node now sits.
|
|
nodeSetPosition(jet, MOUTH[1] - HEAD_AT[1], MOUTH[2] - HEAD_AT[2], MOUTH[3] - HEAD_AT[3])
|
|
|
|
-- The flame's own light, so the dragon is lit by what it is breathing.
|
|
local breathLight = lightNew(LIGHT_POINT)
|
|
|
|
nodeSetParent(breathLight, jet)
|
|
lightSetColor(breathLight, 255, 160, 60)
|
|
lightSetRange(breathLight, 14)
|
|
lightSetIntensity(breathLight, 0)
|
|
|
|
local breath = emitterNew(jet)
|
|
emitterSetMax(breath, 400)
|
|
emitterSetBlend(breath, PARTICLE_ADD)
|
|
emitterSetRadius(breath, 0.05)
|
|
emitterSetSpread(breath, 13)
|
|
emitterSetSpeed(breath, 4.5, 9.5)
|
|
emitterSetDrag(breath, 3.0)
|
|
emitterSetLife(breath, 0.28, 0.55)
|
|
emitterSetSize(breath, 0.10, 1.30)
|
|
emitterSetRate(breath, 260)
|
|
emitterSetColor(breath, 255, 244, 200, 255, 235, 60, 0, 0)
|
|
|
|
-- The tagline is measured in backdropBegin instead, once a font has been selected: at this point
|
|
-- the document renderer has not chosen one, and fontToSprite ends the game without it.
|
|
|
|
-- ===== The backdrop ==========================================================================
|
|
--
|
|
-- Built now and left unlit. Bringing it up is a ramp on the emissive colours rather than anything
|
|
-- appearing, so the grid rises out of the dark the way a light comes on instead of cutting in.
|
|
|
|
local function quad(p, i, x0, z0, x1, z1, x2, z2, x3, z3)
|
|
local base = #p / 3
|
|
|
|
for _, c in ipairs({ { x0, z0 }, { x1, z1 }, { x2, z2 }, { x3, z3 } }) do
|
|
p[#p + 1] = c[1]
|
|
p[#p + 1] = 0
|
|
p[#p + 1] = c[2]
|
|
end
|
|
for _, n in ipairs({ 1, 2, 3, 1, 3, 4 }) do
|
|
i[#i + 1] = base + n
|
|
end
|
|
end
|
|
|
|
|
|
-- A line of constant width thins to nothing as it recedes and breaks into crawling dots; widening
|
|
-- it with distance holds it at about the same width on screen the whole way to the horizon.
|
|
local function spread(z)
|
|
return 1 + (-z) / SPREAD
|
|
end
|
|
|
|
|
|
-- The height at z that lies on the horizon line, so the bar and the sun's cut land on the same row.
|
|
local function horizonY(z)
|
|
return CAM_Y - (CAM_Z - z) * math.tan(math.rad(HORIZ_D))
|
|
end
|
|
|
|
|
|
local function gridMesh()
|
|
local p = {}
|
|
local i = {}
|
|
local far = -DEPTH * CELL
|
|
local wide = HALF_X * CELL
|
|
local hN = LINE_W / 2
|
|
local hF = hN * spread(far)
|
|
|
|
for n = -HALF_X, HALF_X do
|
|
local x = n * CELL
|
|
|
|
quad(p, i, x - hN, CELL, x + hN, CELL, x + hF, far, x - hF, far)
|
|
end
|
|
for n = -1, DEPTH do
|
|
local z = -n * CELL
|
|
local h = CROSS_W / 2 * spread(z)
|
|
|
|
quad(p, i, -wide, z + h, wide, z + h, wide, z - h, -wide, z - h)
|
|
end
|
|
|
|
return meshNew(p, nil, nil, i)
|
|
end
|
|
|
|
|
|
-- The sun: the part of a disc above yClip, as a fan from the middle of the chord.
|
|
local function sunMesh(yClip)
|
|
local p = { 0, yClip, 0 }
|
|
local i = {}
|
|
local a0 = math.asin(math.max(math.min(yClip / SUN_R, 1), -1))
|
|
|
|
for n = 0, SUN_SEGMENTS do
|
|
local a = a0 + (n / SUN_SEGMENTS) * (math.pi - a0 * 2)
|
|
|
|
p[#p + 1] = math.cos(a) * SUN_R
|
|
p[#p + 1] = math.sin(a) * SUN_R
|
|
p[#p + 1] = 0
|
|
end
|
|
for n = 1, SUN_SEGMENTS do
|
|
i[#i + 1] = 1
|
|
i[#i + 1] = n + 1
|
|
i[#i + 1] = n + 2
|
|
end
|
|
|
|
return meshNew(p, nil, nil, i)
|
|
end
|
|
|
|
|
|
-- Unlit, and brought up by their base colour rather than by emissive. The intro has a key light
|
|
-- and a rim for the logo, and a lit material with a black base still catches their specular: the
|
|
-- grid and the sun showed as a grey sheen from the first frame, lit before they were meant to
|
|
-- exist. Unlit takes the lights out of it entirely and the ramp below is the only thing that
|
|
-- decides when the backdrop appears.
|
|
local gridMat = materialNew()
|
|
materialSetColor(gridMat, 0, 0, 0)
|
|
materialSetUnlit(gridMat, true)
|
|
materialSetDoubleSided(gridMat, true)
|
|
|
|
local sunMat2 = materialNew()
|
|
materialSetColor(sunMat2, 0, 0, 0)
|
|
materialSetUnlit(sunMat2, true)
|
|
materialSetDoubleSided(sunMat2, true)
|
|
|
|
local horizMat = materialNew()
|
|
materialSetColor(horizMat, 0, 0, 0)
|
|
materialSetUnlit(horizMat, true)
|
|
materialSetDoubleSided(horizMat, true)
|
|
|
|
local sliceMat = materialNew()
|
|
materialSetColor(sliceMat, FOG_R, FOG_G, FOG_B)
|
|
materialSetUnlit(sliceMat, true)
|
|
materialSetDoubleSided(sliceMat, true)
|
|
|
|
local backdrop = nodeNew()
|
|
local grid = nodeNew()
|
|
|
|
nodeSetMesh(grid, gridMesh(), gridMat)
|
|
nodeSetParent(grid, backdrop)
|
|
|
|
local horizon = nodeNew()
|
|
nodeSetMesh(horizon, meshPlane(HORIZ_W * 2, HORIZ_H), horizMat)
|
|
nodeSetRotation(horizon, 90, 0, 0)
|
|
nodeSetPosition(horizon, 0, horizonY(HORIZ_Z), HORIZ_Z)
|
|
nodeSetParent(horizon, backdrop)
|
|
|
|
local sunCut = horizonY(SUN_Z) - SUN_Y
|
|
local backSun = nodeNew()
|
|
|
|
nodeSetMesh(backSun, sunMesh(sunCut), sunMat2)
|
|
nodeSetPosition(backSun, 0, SUN_Y, SUN_Z)
|
|
nodeSetParent(backSun, backdrop)
|
|
|
|
for n = 1, SUN_SLICES do
|
|
local slice = nodeNew()
|
|
local height = 0.62 - (n - 1) * 0.07
|
|
local y = sunCut + 0.5 + (n - 1) * 1.3
|
|
local w = math.sqrt(math.max(SUN_R * SUN_R - (math.abs(y) + height / 2) ^ 2, 0))
|
|
|
|
nodeSetMesh(slice, meshPlane(w * 2, height), sliceMat)
|
|
nodeSetRotation(slice, 90, 0, 0)
|
|
nodeSetPosition(slice, 0, SUN_Y + y, SUN_Z + 0.4)
|
|
nodeSetParent(slice, backdrop)
|
|
end
|
|
|
|
|
|
local camera = nodeNew()
|
|
nodeSetPosition(camera, 0, CAM_Y, CAM_Z)
|
|
cameraSet(camera)
|
|
|
|
|
|
|
|
|
|
-- The backdrop is the disc for the intro and the 3D scene after it. The grid section of
|
|
-- Singe/menuBackground.mkv is never reached: this renderer has the GPU device the document needs,
|
|
-- which is the same one the scene draws with, so the grid is drawn rather than played.
|
|
-- One frame of the backdrop's timeline.
|
|
function backdropFrame()
|
|
local t = (singeGetTicks() - backdropStarted) / 1000.0
|
|
local fly = span(t, FLY_START, FLY_END)
|
|
local tag = span(t, TAG_START, TAG_END)
|
|
|
|
if not blown then
|
|
blown = true
|
|
emitterBurst(smoke, 85)
|
|
emitterBurst(core, 110)
|
|
emitterBurst(flash, 5)
|
|
emitterBurst(sparks, 130)
|
|
lightSetIntensity(fireLight, 400)
|
|
end
|
|
|
|
-- The fire's light dies over the first second and a half.
|
|
lightSetIntensity(fireLight, 400 * (1 - span(t, 0.05, 1.5)))
|
|
|
|
-- The grid comes up out of the dark while the smoke is still clearing, so the two overlap and
|
|
-- there is no moment where one thing has ended and the next has not begun.
|
|
local lit = span(t, GRID_UP, GRID_LIT)
|
|
|
|
if (lit > 0) and (lit < 1.0001) then
|
|
materialSetColor(gridMat, 255 * lit, 45 * lit, 190 * lit)
|
|
materialSetColor(horizMat, 255 * lit, 150 * lit, 235 * lit)
|
|
materialSetColor(sunMat2, 255 * lit, 130 * lit, 55 * lit)
|
|
-- The sky stops being black at the same time, or the grid would rise into a void.
|
|
sceneSetBackground(FOG_R * lit, FOG_G * lit, FOG_B * lit, 255)
|
|
sceneSetFog(FOG_R, FOG_G, FOG_B, FOG_NEAR, FOG_FAR)
|
|
end
|
|
if lit > 0 then
|
|
-- Scrolling from the moment it is visible, so it is already alive when it arrives.
|
|
nodeSetPosition(grid, 0, 0, ((t - GRID_UP) * GRID_SPEED) % CELL)
|
|
end
|
|
|
|
if fly > 0 then
|
|
local e = easeOut(fly)
|
|
-- Once it has arrived it keeps turning slowly, so the held frame is not a still picture.
|
|
local idle = IDLE_TURN * math.sin((t - FLY_END) * IDLE_RATE) * fly
|
|
local yaw = 360 * SPINS * (1 - e) + idle
|
|
|
|
-- Out of the blast, then up and away once the grid has taken over. Rising rather than
|
|
-- fading: a model has no alpha to fade without touching every material it came with.
|
|
local leave = easeIn(span(t, LIFT_FROM, LIFT_TO))
|
|
|
|
nodeSetVisible(logo, leave < 1)
|
|
nodeSetPosition(logo, 0, LOGO_Y * e + LIFT_RISE * leave, BLAST_Z + (REST_Z - BLAST_Z) * e)
|
|
nodeSetRotation(logo, 0, yaw, 0)
|
|
|
|
-- Wings: beating hard on the way out, easing to a hover once it has arrived.
|
|
local rate = FLAP_FAST + (FLAP_SLOW - FLAP_FAST) * e
|
|
local flap = FLAP_MAX * math.sin(t * rate)
|
|
|
|
nodeSetRotation(wingLeft, -flap, 0, 0)
|
|
nodeSetRotation(wingRite, flap, 0, 0)
|
|
|
|
-- The head rears back, snaps forward, and settles; the flame goes with the strike.
|
|
local pitch = 0
|
|
|
|
if t < REAR_TOP then
|
|
pitch = REAR_ANGLE * span(t, REAR_BACK, REAR_TOP)
|
|
elseif t < REAR_STRIKE then
|
|
local k = span(t, REAR_TOP, REAR_STRIKE)
|
|
|
|
pitch = REAR_ANGLE + (STRIKE_ANGLE - REAR_ANGLE) * k
|
|
else
|
|
pitch = STRIKE_ANGLE * (1 - easeOut(span(t, REAR_STRIKE, REAR_DONE)))
|
|
end
|
|
nodeSetRotation(head, 0, 0, pitch)
|
|
nodeSetRotation(neck, 0, 0, pitch * 0.35)
|
|
|
|
-- The flame leaves the mouth along the dragon's own forward, which the whole logo has been
|
|
-- turning; a 3D emitter takes its direction in world axes, so it is turned here by hand.
|
|
local aim = math.rad(pitch)
|
|
local spin = math.rad(yaw + DRAGON_YAW)
|
|
|
|
emitterSetDirection(breath, math.cos(aim) * math.cos(spin), math.sin(aim), -math.cos(aim) * math.sin(spin))
|
|
if t >= BREATH_START and t < BREATH_END then
|
|
emitterStart(breath)
|
|
lightSetIntensity(breathLight, 26)
|
|
else
|
|
emitterStop(breath)
|
|
lightSetIntensity(breathLight, 0)
|
|
end
|
|
end
|
|
|
|
-- The tagline goes with the logo. It is drawn into the overlay, which the menu clears every
|
|
-- frame, so it has to be redrawn here rather than left standing.
|
|
local gone = span(t, LIFT_FROM, LIFT_TO - 0.4)
|
|
|
|
if (tag > 0) and (gone < 1) then
|
|
colorForeground(235, 225, 240, math.floor(255 * tag * (1 - gone)))
|
|
fontPrint(backdropTagX, backdropTagY, TAGLINE)
|
|
end
|
|
end
|
|
|
|
|
|
|
|
-- Starts the backdrop's clock. showIntro false starts it past the end of the intro, so returning
|
|
-- from a game lands on the settled grid and the same timeline runs on from there.
|
|
function backdropBegin(showIntro)
|
|
-- A font of its own. The document renderer draws everything else through RmlUi and never
|
|
-- selects one, so the tagline -- the only thing put straight into the overlay -- brings it.
|
|
backdropFont = fontLoad("Singe/FreeSansBold.ttf", 30)
|
|
fontSelect(backdropFont)
|
|
fontQuality(FONT_QUALITY_BLENDED)
|
|
|
|
-- fontPrint has no centring, so the tagline is measured once through a throwaway sprite; it
|
|
-- is drawn rather than kept as a sprite because a sprite cannot be tinted and the fade needs
|
|
-- the foreground colour's alpha.
|
|
local measure = fontToSprite(TAGLINE)
|
|
|
|
backdropTagX = math.floor((overlayGetWidth() - spriteGetWidth(measure)) / 2)
|
|
backdropTagY = math.floor(overlayGetHeight() * 0.80)
|
|
spriteUnload(measure)
|
|
|
|
backdropStarted = singeGetTicks() - (showIntro and 0 or (LIFT_TO * 1000))
|
|
blown = not showIntro
|
|
if not showIntro then
|
|
nodeSetVisible(logo, false)
|
|
end
|
|
end
|
|
|
|
|
|
-- Seconds since the backdrop began.
|
|
function backdropTime()
|
|
return (singeGetTicks() - backdropStarted) / 1000.0
|
|
end
|
|
|
|
|
|
-- Whether the intro is over and the screen belongs to whatever sits on the backdrop.
|
|
function backdropReady()
|
|
return backdropTime() >= LIFT_TO
|
|
end
|
|
|
|
|
|
-- How long the whole thing lasts, and how long one turn of the grid takes, so a recording knows
|
|
-- where the intro ends and how much of the loop to keep.
|
|
function backdropIntroSeconds()
|
|
return LIFT_TO
|
|
end
|
|
|
|
|
|
function backdropLoopSeconds()
|
|
return GRID_PERIOD
|
|
end
|
|
|
|
|
|
-- Where the intro ends and the loop begins, which is where the sting hands over to the bed.
|
|
function backdropLoopAt()
|
|
return LOOP_AT
|
|
end
|
|
|
|
|
|
function backdropFinish()
|
|
if backdropFont then
|
|
fontUnload(backdropFont)
|
|
backdropFont = nil
|
|
end
|
|
end
|