singe/testScripts/menuIntro.singe
2026-09-12 22:36:00 -05:00

301 lines
11 KiB
Text

-- Prototype: the menu intro. An explosion, the Singe dragon and name flying out of it toward the
-- camera, and the tagline fading in underneath.
--
-- The fire is 3D emitters rather than the 2D ones the fireball prototype used, because the logo has
-- to come out THROUGH it: 2D particles are drawn with the overlay, on top of the whole scene, so
-- the logo would have flown out behind its own explosion.
local BLAST_Z = -30.0 -- Where the charge goes off.
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.
-- 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. hinge() gives a part the joint it is missing. Coordinates are the
-- model's own: it faces +X, +Y is up, and the wings reach out along +/-Z.
local WING_JOINT = { 0.30, 4.50, 0.45 }
local NECK_JOINT = { 0.30, 2.70, 0.00 }
local HEAD_JOINT = { 1.00, 5.20, 0.00 }
local MOUTH = { 3.55, 5.60, 0.00 }
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 SHOT_TIMES = { 0.05, 0.25, 0.55, 0.95, 1.45, 2.00, 2.60, 3.20, 3.50, 3.70, 4.00, 4.60, 5.20 }
local font = fontLoad("Singe/FreeSansBold.ttf", 30)
local frames = 0
local shotAt = 1
overlaySetResolution(720, 480)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
sceneEnable(true)
sceneSetBackground(0, 0, 0, 255)
sceneSetAmbient(14, 12, 16)
sceneSetBloom(1.2, 0.22)
sceneSetTonemap(TONEMAP_ACES)
-- Gives a part the joint the model does not have: a hinge node at the joint, with the part hung
-- off it and pushed back by the same amount, so the geometry stays where it was and turning the
-- hinge turns the part about the joint. Returns the hinge to turn.
local function hinge(root, name, joint, parent, parentJoint)
local part = nodeFind(name, root)
local pivot = nodeNew()
nodeSetParent(pivot, parent or nodeGetParent(part))
nodeSetPosition(pivot, joint[1] - parentJoint[1], joint[2] - parentJoint[2], joint[3] - parentJoint[3])
nodeSetParent(part, pivot)
nodeSetPosition(part, -joint[1], -joint[2], -joint[3])
return pivot
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, 0, 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("testScripts/Models/SingeText.glb"))
nodeSetParent(text, logo)
local dragon = modelInstance(modelLoad("testScripts/Models/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 ORIGIN = { 0, 0, 0 }
local wingLeft = hinge(dragon, "wingL", { WING_JOINT[1], WING_JOINT[2], -WING_JOINT[3] }, nil, ORIGIN)
local wingRite = hinge(dragon, "wingR", WING_JOINT, nil, ORIGIN)
local neck = hinge(dragon, "neck", NECK_JOINT, nil, ORIGIN)
local head = hinge(dragon, "head", HEAD_JOINT, neck, NECK_JOINT)
-- The flame, on a node at the dragon's mouth so it follows the head round.
local jet = nodeNew()
nodeSetParent(jet, head)
nodeSetPosition(jet, MOUTH[1] - HEAD_JOINT[1], MOUTH[2] - HEAD_JOINT[2], MOUTH[3] - HEAD_JOINT[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)
-- fontPrint has no centring, so the tagline is measured once through a throwaway sprite and drawn
-- by hand from there; 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)
local tagX = math.floor((overlayGetWidth() - spriteGetWidth(measure)) / 2)
local tagY = math.floor(overlayGetHeight() * 0.72)
spriteUnload(measure)
local camera = nodeNew()
nodeSetPosition(camera, 0, 1.2, CAM_Z)
cameraSet(camera)
function onOverlayUpdate()
local t = singeGetTicks() / 1000.0
local fly = span(t, FLY_START, FLY_END)
local tag = span(t, TAG_START, TAG_END)
frames = frames + 1
if frames == 2 then
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)))
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
nodeSetVisible(logo, true)
nodeSetPosition(logo, 0, 0.4 * e, 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
colorBackground(0, 0, 0, 0)
overlayClear()
if tag > 0 then
colorForeground(235, 225, 240, math.floor(255 * tag))
fontPrint(tagX, tagY, TAGLINE)
end
if shotAt <= #SHOT_TIMES and t >= SHOT_TIMES[shotAt] then
shotAt = shotAt + 1
singeScreenshot()
end
if t > 5.6 then
singeQuit()
end
return OVERLAY_UPDATED
end