From 263409a7433ed570a686cb8c2c409333834602d5 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Sat, 12 Sep 2026 22:36:00 -0500 Subject: [PATCH] Working on new menus and intros. --- CHANGELOG | 6 + CMakeLists.txt | 5 + INSTALL | 3 +- assets/Menu.singe | 26 +-- assets/MenuDocument.singe | 257 ++++++++++++++++++++++++++ assets/MenuOverlay.singe | 34 ++++ docs/Manual.adoc | 5 +- src/embedded.h | 2 + src/main.c | 2 + testScripts/gridBackground.singe | 197 ++++++++++++++++++++ testScripts/menuIntro.singe | 301 +++++++++++++++++++++++++++++++ util/embedGlbImages.py | 67 +++++++ 12 files changed, 885 insertions(+), 20 deletions(-) create mode 100644 testScripts/gridBackground.singe create mode 100644 testScripts/menuIntro.singe create mode 100644 util/embedGlbImages.py diff --git a/CHANGELOG b/CHANGELOG index bf0b44099..1f88a293a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1111,6 +1111,12 @@ Fixes version header, and the manual are generated by CMake. The manual is 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 system: ./build-all.sh installs the host packages, then CMake fetches zig (the compiler for every target), the Pi's diff --git a/CMakeLists.txt b/CMakeLists.txt index b7018fb3f..949aa6576 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -239,6 +239,11 @@ add_custom_command( ) 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 singeEmbedLua(thirdparty/luasocket/src/ftp.lua "") singeEmbedLua(thirdparty/luasocket/src/headers.lua "") diff --git a/INSTALL b/INSTALL index 2d6278eb1..006bd62bc 100644 --- a/INSTALL +++ b/INSTALL @@ -2,7 +2,8 @@ SINGE 3.00 ========== (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! diff --git a/assets/Menu.singe b/assets/Menu.singe index 66ecc6d6f..7e67cc7b0 100644 --- a/assets/Menu.singe +++ b/assets/Menu.singe @@ -91,9 +91,9 @@ function loadGameAssets() 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() - return (not toolsActive()) and (discGetFrame() >= DISC_GRID_START) + return (not toolsActive()) and MENU_RENDER.backdropReady() end @@ -147,17 +147,8 @@ end function onOverlayUpdate() - -- 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 (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 intro, then the grid behind the menu, and the overlay cleared ready to be drawn into. + MENU_RENDER.backdrop() -- The network never blocks the frame; it makes progress here instead. masterPump() @@ -272,10 +263,12 @@ if GAME_COUNT == 0 then else 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_LAST_FRAME = 359 - INTRO_DONE = false SHUTDOWN_FROM_PUSH = false -- The service tools share this click with the audio delay tool. @@ -310,10 +303,7 @@ else MENU_RENDER.layout() MENU_RENDER.buildList() - discPlay() - if (not SHOW_INTRO) then - discSkipToFrame(DISC_GRID_START) - end + MENU_RENDER.backdropBegin(SHOW_INTRO) -- Prime the pump gameSelect(GAME_SELECTED) diff --git a/assets/MenuDocument.singe b/assets/MenuDocument.singe index b4408b2c1..49467169f 100644 --- a/assets/MenuDocument.singe +++ b/assets/MenuDocument.singe @@ -31,6 +31,9 @@ -- -- begin() build what drawing needs -- 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 -- buildList() the whole game list is known -- showGame(game) this is the selection now @@ -53,8 +56,228 @@ local MARQUEE_ASPECT_H = 75 local VIDEO_ASPECT_W = 320 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 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) @@ -189,6 +412,40 @@ MENU_RENDER.finish = function() guiDelete(GUI) GUI = nil 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 diff --git a/assets/MenuOverlay.singe b/assets/MenuOverlay.singe index f35d3bd3f..4a962120f 100644 --- a/assets/MenuOverlay.singe +++ b/assets/MenuOverlay.singe @@ -46,6 +46,7 @@ local PAGE_ROWS = 10 local WRAP_BREAK = " [!wb!] " local font = nil +local introDone = false local videoX, videoY, videoW, videoH = 0, 0, 0, 0 local marqueeX, marqueeY, marqueeW = 0, 0, 0 local textX, textY, textW, textH = 0, 0, 0, 0 @@ -205,6 +206,39 @@ MENU_RENDER.finish = function() 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. MENU_RENDER.layout = function() local width = overlayGetWidth() diff --git a/docs/Manual.adoc b/docs/Manual.adoc index 4d5cfd577..b43c90f25 100644 --- a/docs/Manual.adoc +++ b/docs/Manual.adoc @@ -1021,6 +1021,8 @@ Singe/ Support files extracted by the engine controls.cfg.example Template for input mappings click.wav Used by the menu's audio delay and sound tests Manual.pdf This manual + COPYING The GNU General Public License, version 3 + LICENSES Third-party notices and asset credits ActionMax/ One game games.dat Menu entries for the games in this directory 38AmbushAlley.singe A script @@ -14385,7 +14387,8 @@ present = singeHasGpu() ---- 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 <> and the scene calls end the script rather than answering `false` -- there is no way to try and recover. diff --git a/src/embedded.h b/src/embedded.h index cb9c258da..b4633988c 100644 --- a/src/embedded.h +++ b/src/embedded.h @@ -49,6 +49,8 @@ #include "generated/menuBackground_mkv.h" #include "generated/click_wav.h" #include "generated/Manual_pdf.h" +#include "generated/COPYING.h" +#include "generated/LICENSES.h" // LuaSocket #include "generated/ftp_lua.h" diff --git a/src/main.c b/src/main.c index d477c55d8..b36095cc8 100644 --- a/src/main.c +++ b/src/main.c @@ -1818,6 +1818,8 @@ static void _unpackData(const char *exePath, bool absolute) { { "menuBackground.mkv", menuBackground_mkv, menuBackground_mkv_len }, { "click.wav", click_wav, click_wav_len }, { "Manual.pdf", Manual_pdf, Manual_pdf_len }, + { "COPYING", COPYING, COPYING_len }, + { "LICENSES", LICENSES, LICENSES_len }, { "gui.rcss", gui_rcss, gui_rcss_len }, { "scoreBezel.rml", scoreBezel_rml, scoreBezel_rml_len }, { "scoreBezel.rcss", scoreBezel_rcss, scoreBezel_rcss_len }, diff --git a/testScripts/gridBackground.singe b/testScripts/gridBackground.singe new file mode 100644 index 000000000..cc174c5cb --- /dev/null +++ b/testScripts/gridBackground.singe @@ -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 diff --git a/testScripts/menuIntro.singe b/testScripts/menuIntro.singe new file mode 100644 index 000000000..4f31d8e6b --- /dev/null +++ b/testScripts/menuIntro.singe @@ -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 diff --git a/util/embedGlbImages.py b/util/embedGlbImages.py new file mode 100644 index 000000000..64b66415a --- /dev/null +++ b/util/embedGlbImages.py @@ -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('= 8: + binLength, binType = struct.unpack('