Working on new menus and intros.

This commit is contained in:
Scott Duensing 2026-09-12 22:36:00 -05:00
parent ffc1d54a89
commit 263409a743
12 changed files with 885 additions and 20 deletions

View file

@ -1111,6 +1111,12 @@ Fixes
version header, and the manual are generated by CMake. The manual is version header, and the manual are generated by CMake. The manual is
embedded again and extracted as Singe/Manual.pdf. embedded again and extracted as Singe/Manual.pdf.
- The licence Singe is under and the notices its libraries and assets
require are embedded and extracted as Singe/COPYING and
Singe/LICENSES, the way the manual is. Nothing in the build copied
them beside the binary, so a download carried the program without the
terms it is offered under.
- One command builds Singe from a clean checkout on a Debian based - One command builds Singe from a clean checkout on a Debian based
system: ./build-all.sh <platform> <arch> installs the host packages, system: ./build-all.sh <platform> <arch> installs the host packages,
then CMake fetches zig (the compiler for every target), the Pi's then CMake fetches zig (the compiler for every target), the Pi's

View file

@ -239,6 +239,11 @@ add_custom_command(
) )
singeEmbed(${MANUAL_DIR}/Manual.pdf ${GENERATED_DIR}/Manual_pdf.h "") singeEmbed(${MANUAL_DIR}/Manual.pdf ${GENERATED_DIR}/Manual_pdf.h "")
# The licence Singe is under and the notices its third-party libraries and assets require. Both
# have to reach whoever gets the binary, so they travel inside it like the manual does.
singeEmbed(${CMAKE_SOURCE_DIR}/COPYING ${GENERATED_DIR}/COPYING.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/LICENSES ${GENERATED_DIR}/LICENSES.h "")
# Lua libraries # Lua libraries
singeEmbedLua(thirdparty/luasocket/src/ftp.lua "") singeEmbedLua(thirdparty/luasocket/src/ftp.lua "")
singeEmbedLua(thirdparty/luasocket/src/headers.lua "") singeEmbedLua(thirdparty/luasocket/src/headers.lua "")

View file

@ -2,7 +2,8 @@ SINGE 3.00
========== ==========
(For the latest version of this document, visit https://kangaroopunch.com! (For the latest version of this document, visit https://kangaroopunch.com!
The full manual is extracted to Singe/Manual.pdf on first run.) The full manual is extracted to Singe/Manual.pdf on first run, beside
Singe/COPYING and Singe/LICENSES.)
Welcome to Singe! SINGE Is Not a Game Emulator! Welcome to Singe! SINGE Is Not a Game Emulator!

View file

@ -91,9 +91,9 @@ function loadGameAssets()
end end
-- The game page takes input once the disc has reached the grid behind it. -- The game page takes input once the backdrop has finished the intro and settled on the grid.
function menuActive() function menuActive()
return (not toolsActive()) and (discGetFrame() >= DISC_GRID_START) return (not toolsActive()) and MENU_RENDER.backdropReady()
end end
@ -147,17 +147,8 @@ end
function onOverlayUpdate() function onOverlayUpdate()
-- Loop the grid. A machine too slow to show every disc frame can step over the last one, after -- The intro, then the grid behind the menu, and the overlay cleared ready to be drawn into.
-- which the engine starts the disc again from the top; once the intro has played, that is a loop. MENU_RENDER.backdrop()
if discGetFrame() >= DISC_LAST_FRAME or (INTRO_DONE and discGetFrame() < DISC_GRID_START) then
discSkipToFrame(DISC_GRID_START)
end
if discGetFrame() >= DISC_GRID_START then
INTRO_DONE = true
end
colorBackground(0, 0, 0, 0)
overlayClear()
-- The network never blocks the frame; it makes progress here instead. -- The network never blocks the frame; it makes progress here instead.
masterPump() masterPump()
@ -272,10 +263,12 @@ if GAME_COUNT == 0 then
else else
overlaySetResolution(discGetWidth(), discGetHeight()) overlaySetResolution(discGetWidth(), discGetHeight())
-- Where the engine intro ends and the grid section begins in Singe/menuBackground.mkv, and its
-- last frame. A renderer that draws its own grid uses the first as the end of the intro and
-- never reaches the second.
DISC_GRID_START = 180 DISC_GRID_START = 180
DISC_LAST_FRAME = 359 DISC_LAST_FRAME = 359
INTRO_DONE = false
SHUTDOWN_FROM_PUSH = false SHUTDOWN_FROM_PUSH = false
-- The service tools share this click with the audio delay tool. -- The service tools share this click with the audio delay tool.
@ -310,10 +303,7 @@ else
MENU_RENDER.layout() MENU_RENDER.layout()
MENU_RENDER.buildList() MENU_RENDER.buildList()
discPlay() MENU_RENDER.backdropBegin(SHOW_INTRO)
if (not SHOW_INTRO) then
discSkipToFrame(DISC_GRID_START)
end
-- Prime the pump -- Prime the pump
gameSelect(GAME_SELECTED) gameSelect(GAME_SELECTED)

View file

@ -31,6 +31,9 @@
-- --
-- begin() build what drawing needs -- begin() build what drawing needs
-- finish() give it back -- finish() give it back
-- backdropBegin(showIntro) start the background
-- backdrop() advance it, and clear the overlay ready to be drawn into
-- backdropReady() the intro is over and the menu may take input
-- layout() place the regions, from the overlay size -- layout() place the regions, from the overlay size
-- buildList() the whole game list is known -- buildList() the whole game list is known
-- showGame(game) this is the selection now -- showGame(game) this is the selection now
@ -53,8 +56,228 @@ local MARQUEE_ASPECT_H = 75
local VIDEO_ASPECT_W = 320 local VIDEO_ASPECT_W = 320
local VIDEO_ASPECT_H = 200 local VIDEO_ASPECT_H = 200
-- The backdrop: a neon grid running to a horizon under a sliced sun, drawn by the 3D scene. The
-- numbers are world units; the fog colour is also the background, so the grid fades into it rather
-- than stopping at an edge.
local GRID_CELL = 6.0 -- Spacing between grid lines.
local GRID_LINE_W = 0.12 -- Width of a line running away from the camera, at the near end.
local GRID_CROSS_W = 0.18 -- Width of a cross line, at the near end.
local GRID_SPREAD = 16.0 -- Distance over which a line doubles in width. See backdropQuad().
local GRID_HALF_X = 12 -- Lines each side of the centre.
local GRID_DEPTH = 14 -- Cross lines ahead of the camera, past where the fog ends.
local GRID_SPEED = 9.0 -- Units a second the grid moves toward the camera.
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 -- Horizontal bands cut out of the sun, widest at the bottom.
local SUN_SEGMENTS = 72 -- Segments around the sun's arc.
local HORIZ_W = 70.0 -- Half the width of the horizon bar; it has to reach both edges.
local HORIZ_H = 0.5 -- Its height. Bloom makes it read thicker than this.
local HORIZ_Z = -38.0 -- Just beyond the sun, so the sun stands in front of it.
local HORIZ_D = 2.2 -- Degrees below eye level the horizon is drawn at. The true one is
-- at eye level, infinitely far away, and the grid fades into the fog
-- well before it; dropping the line to where the grid actually ends
-- closes the gap that would otherwise sit under the bar.
local CAM_Y = 2.7 -- Eye height.
local CAM_Z = 6.0
local BLOOM_LEVEL = 0.25
local BLOOM_AMOUNT = 0.9
local videoX, videoY, videoW, videoH = 0, 0, 0, 0 local videoX, videoY, videoW, videoH = 0, 0, 0, 0
local scrollPending = false local scrollPending = false
local backdropRoot = nil
local backdropGrid = nil
local introPlaying = false
-- A vertex on the ground plane, appended to the positions of a mesh being built.
local function backdropVertex(p, x, z)
p[#p + 1] = x
p[#p + 1] = 0
p[#p + 1] = z
end
-- One flat quad in the XZ plane, four corners in order, appended to a mesh being built.
local function backdropQuad(p, i, x0, z0, x1, z1, x2, z2, x3, z3)
local base = #p / 3
backdropVertex(p, x0, z0)
backdropVertex(p, x1, z1)
backdropVertex(p, x2, z2)
backdropVertex(p, x3, z3)
for _, n in ipairs({ 1, 2, 3, 1, 3, 4 }) do
i[#i + 1] = base + n
end
end
-- How much wider than its near end a line is at z, so that it holds its width on screen. A line of
-- constant width in the world thins as it recedes, and once it covers less than a pixel it breaks
-- into a dotted mess that crawls as the grid moves; widening it with distance is what stops that.
local function backdropSpread(z)
return 1 + (-z) / GRID_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
-- of the screen even though they are at different depths.
local function backdropHorizonY(z)
return CAM_Y - (CAM_Z - z) * math.tan(math.rad(HORIZ_D))
end
-- Half the width of the sun at height y above its middle.
local function backdropChord(y)
return math.sqrt(math.max(SUN_R * SUN_R - y * y, 0))
end
-- The grid: lines running away from the camera, and cross lines one cell beyond each end so the
-- whole thing can be slid by one cell and wrapped without a seam.
local function backdropGridMesh()
local p = {}
local i = {}
local far = -GRID_DEPTH * GRID_CELL
local wide = GRID_HALF_X * GRID_CELL
local hN = GRID_LINE_W / 2
local hF = hN * backdropSpread(far)
for n = -GRID_HALF_X, GRID_HALF_X do
local x = n * GRID_CELL
backdropQuad(p, i, x - hN, GRID_CELL, x + hN, GRID_CELL, x + hF, far, x - hF, far)
end
for n = -1, GRID_DEPTH do
local z = -n * GRID_CELL
local h = GRID_CROSS_W / 2 * backdropSpread(z)
backdropQuad(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. Cutting the
-- geometry is what puts the sun behind the horizon; letting a ground plane hide its lower half
-- instead only works while the ground reaches far enough, and the grid's gaps show through it.
local function backdropSunMesh(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
-- Builds the backdrop. Everything but the camera hangs off one node, so the whole thing can be
-- hidden with a single call while the disc plays the intro in front of it.
local function backdropBuild()
local sunCut = backdropHorizonY(SUN_Z) - SUN_Y
local neon
local sunMat
local sliceMat
local horizMat
local camera
local sun
local horizon
sceneEnable(true)
sceneSetAmbient(0, 0, 0)
sceneSetFog(FOG_R, FOG_G, FOG_B, FOG_NEAR, FOG_FAR)
sceneSetBloom(BLOOM_LEVEL, BLOOM_AMOUNT)
sceneSetTonemap(TONEMAP_NEUTRAL)
-- Transparent until the intro is over, so the disc is what shows through the 3D layer.
sceneSetBackground(FOG_R, FOG_G, FOG_B, 0)
neon = materialNew()
materialSetColor(neon, 0, 0, 0)
materialSetEmissive(neon, 255, 45, 190)
materialSetDoubleSided(neon, true)
sunMat = materialNew()
materialSetColor(sunMat, 0, 0, 0)
materialSetEmissive(sunMat, 255, 130, 55)
materialSetDoubleSided(sunMat, true)
-- The slices are holes cut in the sun, so they are painted in the colour behind it.
sliceMat = materialNew()
materialSetColor(sliceMat, FOG_R, FOG_G, FOG_B)
materialSetUnlit(sliceMat, true)
materialSetDoubleSided(sliceMat, true)
-- Hotter than the grid's own magenta because the fog it sits in halves it: at the grid's
-- colour the bar sinks into the far rows instead of capping them.
horizMat = materialNew()
materialSetColor(horizMat, 0, 0, 0)
materialSetEmissive(horizMat, 255, 150, 235)
materialSetDoubleSided(horizMat, true)
backdropRoot = nodeNew()
nodeSetVisible(backdropRoot, false)
backdropGrid = nodeNew()
nodeSetMesh(backdropGrid, backdropGridMesh(), neon)
nodeSetParent(backdropGrid, backdropRoot)
horizon = nodeNew()
nodeSetMesh(horizon, meshPlane(HORIZ_W * 2, HORIZ_H), horizMat)
nodeSetRotation(horizon, 90, 0, 0)
nodeSetPosition(horizon, 0, backdropHorizonY(HORIZ_Z), HORIZ_Z)
nodeSetParent(horizon, backdropRoot)
sun = nodeNew()
nodeSetMesh(sun, backdropSunMesh(sunCut), sunMat)
nodeSetPosition(sun, 0, SUN_Y, SUN_Z)
nodeSetParent(sun, backdropRoot)
-- Each slice is cut to the chord of the disc at its own height: one wider than the sun shows
-- its corners, and being opaque it also punches a rectangular hole in the horizon bar behind.
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
nodeSetMesh(slice, meshPlane(backdropChord(math.abs(y) + height / 2) * 2, height), sliceMat)
nodeSetRotation(slice, 90, 0, 0)
nodeSetPosition(slice, 0, SUN_Y + y, SUN_Z + 0.4)
nodeSetParent(slice, backdropRoot)
end
camera = nodeNew()
nodeSetPosition(camera, 0, CAM_Y, CAM_Z)
nodeSetRotation(camera, -2.0, 0, 0)
cameraSet(camera)
end
-- The intro is over: stop the disc and let the grid through.
local function backdropShow()
introPlaying = false
discPause()
sceneSetBackground(FOG_R, FOG_G, FOG_B, 255)
nodeSetVisible(backdropRoot, true)
end
function menuElement(id) function menuElement(id)
@ -189,6 +412,40 @@ MENU_RENDER.finish = function()
guiDelete(GUI) guiDelete(GUI)
GUI = nil GUI = nil
end end
sceneEnable(false)
end
-- 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.
MENU_RENDER.backdropBegin = function(showIntro)
backdropBuild()
introPlaying = showIntro
if showIntro then
discPlay()
else
backdropShow()
end
end
MENU_RENDER.backdrop = function()
if introPlaying and discGetFrame() >= DISC_GRID_START then
backdropShow()
end
if not introPlaying then
nodeSetPosition(backdropGrid, 0, 0, (singeGetTicks() / 1000.0 * GRID_SPEED) % GRID_CELL)
end
-- Transparent, so the scene behind the overlay is what shows.
colorBackground(0, 0, 0, 0)
overlayClear()
end
MENU_RENDER.backdropReady = function()
return not introPlaying
end end

View file

@ -46,6 +46,7 @@ local PAGE_ROWS = 10
local WRAP_BREAK = " [!wb!] " local WRAP_BREAK = " [!wb!] "
local font = nil local font = nil
local introDone = false
local videoX, videoY, videoW, videoH = 0, 0, 0, 0 local videoX, videoY, videoW, videoH = 0, 0, 0, 0
local marqueeX, marqueeY, marqueeW = 0, 0, 0 local marqueeX, marqueeY, marqueeW = 0, 0, 0
local textX, textY, textW, textH = 0, 0, 0, 0 local textX, textY, textW, textH = 0, 0, 0, 0
@ -205,6 +206,39 @@ MENU_RENDER.finish = function()
end end
-- The backdrop is the disc: the engine intro, then the grid section played over and over. A
-- machine with no GPU device has nothing to draw a grid with, so this renderer plays the one in
-- Singe/menuBackground.mkv; Singe/MenuDocument.singe draws its own instead.
MENU_RENDER.backdropBegin = function(showIntro)
introDone = not showIntro
discPlay()
if not showIntro then
discSkipToFrame(DISC_GRID_START)
end
end
MENU_RENDER.backdrop = function()
-- Loop the grid. A machine too slow to show every disc frame can step over the last one, after
-- which the engine starts the disc again from the top; once the intro has played, that is a loop.
if discGetFrame() >= DISC_LAST_FRAME or (introDone and discGetFrame() < DISC_GRID_START) then
discSkipToFrame(DISC_GRID_START)
end
if discGetFrame() >= DISC_GRID_START then
introDone = true
end
-- Transparent, so the disc behind the overlay is what shows.
colorBackground(0, 0, 0, 0)
overlayClear()
end
MENU_RENDER.backdropReady = function()
return discGetFrame() >= DISC_GRID_START
end
-- The cabinet fills the left; the marquee, the attract video and the details stack down the right. -- The cabinet fills the left; the marquee, the attract video and the details stack down the right.
MENU_RENDER.layout = function() MENU_RENDER.layout = function()
local width = overlayGetWidth() local width = overlayGetWidth()

View file

@ -1021,6 +1021,8 @@ Singe/ Support files extracted by the engine
controls.cfg.example Template for input mappings controls.cfg.example Template for input mappings
click.wav Used by the menu's audio delay and sound tests click.wav Used by the menu's audio delay and sound tests
Manual.pdf This manual Manual.pdf This manual
COPYING The GNU General Public License, version 3
LICENSES Third-party notices and asset credits
ActionMax/ One game ActionMax/ One game
games.dat Menu entries for the games in this directory games.dat Menu entries for the games in this directory
38AmbushAlley.singe A script 38AmbushAlley.singe A script
@ -14385,7 +14387,8 @@ present = singeHasGpu()
---- ----
Whether the GPU device came up. Everything drawn through it is unavailable Whether the GPU device came up. Everything drawn through it is unavailable
without one: the 3D scene, the particles, and every GUI document. Ask this without one: the 3D scene, 3D particles, and every GUI document. 2D
particles are drawn with the overlay and need no device. Ask this
before asking for any of them, because <<guinew,guiNew>> and the scene calls before asking for any of them, because <<guinew,guiNew>> and the scene calls
end the script rather than answering `false` -- there is no way to try and end the script rather than answering `false` -- there is no way to try and
recover. recover.

View file

@ -49,6 +49,8 @@
#include "generated/menuBackground_mkv.h" #include "generated/menuBackground_mkv.h"
#include "generated/click_wav.h" #include "generated/click_wav.h"
#include "generated/Manual_pdf.h" #include "generated/Manual_pdf.h"
#include "generated/COPYING.h"
#include "generated/LICENSES.h"
// LuaSocket // LuaSocket
#include "generated/ftp_lua.h" #include "generated/ftp_lua.h"

View file

@ -1818,6 +1818,8 @@ static void _unpackData(const char *exePath, bool absolute) {
{ "menuBackground.mkv", menuBackground_mkv, menuBackground_mkv_len }, { "menuBackground.mkv", menuBackground_mkv, menuBackground_mkv_len },
{ "click.wav", click_wav, click_wav_len }, { "click.wav", click_wav, click_wav_len },
{ "Manual.pdf", Manual_pdf, Manual_pdf_len }, { "Manual.pdf", Manual_pdf, Manual_pdf_len },
{ "COPYING", COPYING, COPYING_len },
{ "LICENSES", LICENSES, LICENSES_len },
{ "gui.rcss", gui_rcss, gui_rcss_len }, { "gui.rcss", gui_rcss, gui_rcss_len },
{ "scoreBezel.rml", scoreBezel_rml, scoreBezel_rml_len }, { "scoreBezel.rml", scoreBezel_rml, scoreBezel_rml_len },
{ "scoreBezel.rcss", scoreBezel_rcss, scoreBezel_rcss_len }, { "scoreBezel.rcss", scoreBezel_rcss, scoreBezel_rcss_len },

View file

@ -0,0 +1,197 @@
-- Prototype: the menu's grid background drawn live instead of played from a video file.
local CELL = 6.0 -- Spacing between grid lines, world units.
local LINE_W = 0.12 -- Width of a line running away from the camera, at the near end.
local CROSS_W = 0.18 -- Width of a cross line, at the near end.
local SPREAD = 16.0 -- Distance over which a line doubles in width. See quad().
local HALF_X = 12 -- Lines each side of the centre.
local DEPTH = 14 -- Cross lines ahead of the camera, past where the fog ends.
local SPEED = 9.0 -- Units a second the grid moves toward the camera.
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 HORIZ_W = 70.0 -- Half the width of the horizon bar; it has to reach both edges.
local HORIZ_H = 0.5 -- Its height. Bloom makes it read thicker than this.
local HORIZ_Z = -38.0 -- Just beyond the sun, so the sun stands in front of it.
local HORIZ_D = 2.2 -- Degrees below eye level the horizon is drawn at. The true one is
-- at eye level, infinitely far away, and the grid fades into the fog
-- well before it; dropping the line to where the grid actually ends
-- closes the gap that would otherwise sit under the bar.
local CAM_Y = 2.7 -- Eye height.
local CAM_Z = 6.0
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
local frames = 0
fontSelect(font)
sceneEnable(true)
sceneSetBackground(FOG_R, FOG_G, FOG_B, 255)
sceneSetAmbient(0, 0, 0)
sceneSetFog(FOG_R, FOG_G, FOG_B, FOG_NEAR, FOG_FAR)
sceneSetBloom(0.25, 0.9)
sceneSetTonemap(TONEMAP_NEUTRAL)
-- One flat quad in the XZ plane, four corners in order, appended to a mesh being built.
local function quad(p, i, x0, z0, x1, z1, x2, z2, x3, z3)
local base = #p / 3
table.insert(p, x0) table.insert(p, 0) table.insert(p, z0)
table.insert(p, x1) table.insert(p, 0) table.insert(p, z1)
table.insert(p, x2) table.insert(p, 0) table.insert(p, z2)
table.insert(p, x3) table.insert(p, 0) table.insert(p, z3)
for _, n in ipairs({ 1, 2, 3, 1, 3, 4 }) do
table.insert(i, base + n)
end
end
-- The height at z that lies on the horizon line, so the bar and the sun's cut land on the same row
-- of the screen even though they are at different depths.
local function horizonY(z)
return CAM_Y - (CAM_Z - z) * math.tan(math.rad(HORIZ_D))
end
-- How much wider than its near end a line is at z, so that it holds its width on screen. A line of
-- constant width in the world thins as it recedes, and once it covers less than a pixel it breaks
-- into a dotted mess that crawls as the grid moves; widening it with distance is what stops that.
local function spread(z)
return 1 + (-z) / SPREAD
end
-- The grid: lines running away from the camera, and cross lines one cell beyond each end so the
-- whole thing can be slid by one cell and wrapped without a seam.
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. Cutting the
-- geometry is what puts the sun behind the horizon; letting the ground plane hide its lower half
-- instead only works while the ground reaches far enough, and the grid's gaps show through it.
local function sunMesh(radius, segments, yClip)
local p = { 0, yClip, 0 }
local i = {}
local a0 = math.asin(math.max(math.min(yClip / radius, 1), -1))
for n = 0, segments do
local a = a0 + (n / segments) * (math.pi - a0 * 2)
table.insert(p, math.cos(a) * radius)
table.insert(p, math.sin(a) * radius)
table.insert(p, 0)
end
for n = 1, segments do
table.insert(i, 1)
table.insert(i, n + 1)
table.insert(i, n + 2)
end
return meshNew(p, nil, nil, i)
end
-- Half the width of the sun at height y above its middle.
local function sunChord(radius, y)
return math.sqrt(math.max(radius * radius - y * y, 0))
end
local neon = materialNew()
materialSetColor(neon, 0, 0, 0)
materialSetEmissive(neon, 255, 45, 190)
materialSetDoubleSided(neon, true)
local sunMat = materialNew()
materialSetColor(sunMat, 0, 0, 0)
materialSetEmissive(sunMat, 255, 130, 55)
materialSetDoubleSided(sunMat, true)
local slotMat = materialNew()
materialSetColor(slotMat, FOG_R, FOG_G, FOG_B)
materialSetUnlit(slotMat, true)
materialSetDoubleSided(slotMat, true)
local grid = nodeNew()
nodeSetMesh(grid, gridMesh(), neon)
-- The glowing line the grid runs to. It is hotter than the grid's own magenta because the fog it
-- sits in halves it: at the grid's colour it sinks into the far rows instead of capping them.
local horizMat = materialNew()
materialSetColor(horizMat, 0, 0, 0)
materialSetEmissive(horizMat, 255, 150, 235)
materialSetDoubleSided(horizMat, true)
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)
-- The sun is cut off at the horizon line, so it stands on the bar rather than floating over it.
local sunCut = horizonY(SUN_Z) - SUN_Y
local sun = nodeNew()
nodeSetMesh(sun, sunMesh(SUN_R, 72, sunCut), sunMat)
nodeSetPosition(sun, 0, SUN_Y, SUN_Z)
-- The sun's horizontal slices, widest and thickest at the bottom. Each is cut to the chord of the
-- disc at its own height: a slice wider than the sun shows its corners, and being opaque it also
-- punches a rectangular hole in the horizon bar passing behind.
for n = 1, 6 do
local slot = nodeNew()
local h = 0.62 - (n - 1) * 0.07
local y = sunCut + 0.5 + (n - 1) * 1.3
local w = sunChord(SUN_R, math.abs(y) + h / 2)
nodeSetMesh(slot, meshPlane(w * 2, h), slotMat)
nodeSetRotation(slot, 90, 0, 0)
nodeSetPosition(slot, 0, SUN_Y + y, SUN_Z + 0.4)
end
local camera = nodeNew()
nodeSetPosition(camera, 0, CAM_Y, CAM_Z)
nodeSetRotation(camera, -2.0, 0, 0)
cameraSet(camera)
function onOverlayUpdate()
local t = singeGetTicks() / 1000.0
frames = frames + 1
nodeSetPosition(grid, 0, 0, (t * SPEED) % CELL)
overlayClear()
-- Nothing is drawn into the overlay; the menu draws its own.
if frames == 30 or frames == 48 then
singeScreenshot()
end
if frames == 60 then
singeQuit()
end
return OVERLAY_UPDATED
end

301
testScripts/menuIntro.singe Normal file
View file

@ -0,0 +1,301 @@
-- 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

67
util/embedGlbImages.py Normal file
View file

@ -0,0 +1,67 @@
# Embeds a .glb's externally referenced images into the file itself, which is what Singe's loader
# needs: it reads images from buffer views and will not go looking for a file beside the model.
# Downloaded packs often ship a .glb that still points at a shared texture atlas (Kenney's arcade
# models name "Textures/colormap.png"), and those load untextured until this is run over them.
# packGlb.py does the same job for a .gltf with external buffers; this one leaves the buffer alone
# and only pulls the images in.
# Usage: python3 util/embedGlbImages.py in.glb out.glb [--base DIRECTORY]
import argparse
import json
import os
import struct
import sys
mimeTypes = {'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'ktx2': 'image/ktx2', 'webp': 'image/webp', 'basis': 'image/basis'}
parser = argparse.ArgumentParser()
parser.add_argument('source')
parser.add_argument('output')
parser.add_argument('--base', default=None, help='where the external images live (default: beside the source)')
args = parser.parse_args()
base = args.base if args.base else os.path.dirname(args.source)
with open(args.source, 'rb') as f:
magic, version, length = struct.unpack('<III', f.read(12))
if magic != 0x46546C67:
sys.exit('%s is not a .glb' % args.source)
jsonLength, jsonType = struct.unpack('<II', f.read(8))
gltf = json.loads(f.read(jsonLength).decode('utf-8'))
binary = bytearray()
rest = f.read()
if len(rest) >= 8:
binLength, binType = struct.unpack('<II', rest[:8])
binary = bytearray(rest[8:8 + binLength])
views = gltf.setdefault('bufferViews', [])
embedded = 0
for image in gltf.get('images', []):
uri = image.get('uri')
if uri is None or uri.startswith('data:'):
continue
path = os.path.join(base, uri)
if not os.path.exists(path):
sys.exit('%s references %s, which is not beside it' % (args.source, uri))
with open(path, 'rb') as f:
data = f.read()
while len(binary) % 4:
binary.append(0)
views.append({'buffer': 0, 'byteOffset': len(binary), 'byteLength': len(data)})
binary.extend(data)
image['bufferView'] = len(views) - 1
image['mimeType'] = mimeTypes.get(uri.rsplit('.', 1)[-1].lower(), 'image/png')
del image['uri']
embedded += 1
gltf['buffers'] = [{'byteLength': len(binary)}]
jsonChunk = json.dumps(gltf, separators=(',', ':')).encode('utf-8')
while len(jsonChunk) % 4:
jsonChunk += b' '
while len(binary) % 4:
binary.append(0)
with open(args.output, 'wb') as f:
f.write(struct.pack('<III', 0x46546C67, 2, 12 + 8 + len(jsonChunk) + 8 + len(binary)))
f.write(struct.pack('<II', len(jsonChunk), 0x4E4F534A))
f.write(jsonChunk)
f.write(struct.pack('<II', len(binary), 0x004E4942))
f.write(binary)
print('%s: %d image(s) embedded, %d bytes' % (args.output, embedded, os.path.getsize(args.output)))