-- Prototype: the menu intro, through to the menu's own backdrop, as one continuous shot. -- -- An explosion, the Singe dragon and name flying out of it toward the camera, the tagline fading in -- underneath -- 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 the menu sits on left running. It is one scene -- from start to finish rather than an intro handing over to something else: there is no cut, and -- nothing has to be loaded at the join. -- -- 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 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 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 local GRID_SPEED = 9.0 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 = 2.5 -- Where the logo settles, so it sits on the horizon the grid runs to. -- 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 SHOT_TIMES = { 0.25, 1.45, 3.00, 3.60, 4.20, 4.70, 5.10, 5.60, 6.10, 6.60, 7.10, 7.60 } 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) -- 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("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 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) -- 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) -- ===== 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) 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))) -- 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 colorBackground(0, 0, 0, 0) overlayClear() 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(tagX, tagY, TAGLINE) end if shotAt <= #SHOT_TIMES and t >= SHOT_TIMES[shotAt] then shotAt = shotAt + 1 singeScreenshot() end if t > 8.2 then singeQuit() end return OVERLAY_UPDATED end