Particle system, player controllers, and some more shadow tests.

This commit is contained in:
Scott Duensing 2026-09-06 16:02:28 -05:00
parent 9f5142738b
commit 33b45d0527
34 changed files with 8627 additions and 926 deletions

1
.gitignore vendored
View file

@ -17,7 +17,6 @@ docs/Manual.pdf
docs/.asciidoctor/
.claude/settings.local.json
**/__pycache__/
/util/perl/
# Extracted by the binary when it is run from here; never source.
/Singe/

View file

@ -33,6 +33,66 @@ API Changes
SSE4.1 on x86 (2008 and later); the engine's first C++ (one wrapper
file and the library), statically linked, no runtime to install.
- Soft bodies. softNew makes cloth or a pressure body from a node's
mesh (particles from its welded vertices, Jolt's stretch, shear and
bend constraints, pressure for balloons) or a rope from the node to a
point with a tube mesh the engine makes; they collide with everything
rigid and the mesh is rewritten every frame. softPin holds particles
in place or to nodes; stiffness, mass, damping and pressure are
tunable. meshPlane takes subdivisions; nodeSetMaterial changes a
material without the mesh. New calls: soft*, and the SOFT_*
constants; see Soft bodies in the Physics chapter.
- Ragdolls. ragdollNew derives capsules and swing-twist joints from a
skinned model's skeleton; ragdollActivate hands the skeleton to
physics from its current pose and the mesh follows the bones as it
falls; motors (ragdollSetStrength) pull it back toward that pose;
ragdollApplyImpulse shoves a bone; ragdollDeactivate returns it to
animation. New calls: ragdoll*; see Ragdolls in the Physics chapter.
- Water. bodySetWater fills a static body with water: dynamic bodies
inside float, sink and drift by Jolt's buoyancy against its surface,
with drag and a current (bodySetCurrent, bodySetBuoyancy per body);
players swim in it (playerIsSwimming, playerSetSwim, three-axis
playerMove); a buoyant hull with vehicleNew(node, VEHICLE_BOAT) gets
a propeller and a rudder (vehicleSetThrust, vehicleSetRudder).
- Vehicles. A dynamic body becomes a car, a motorcycle or a tank on
Jolt's vehicle constraint: wheels are nodes the engine poses (spin,
steer, suspension), the script drives with throttle, steering, brake
and hand brake, and engine, gears, suspension, steering, brakes and
anti-roll bars are tunable. Motorcycles lean and balance with a spring
scaled to the chassis; tanks pivot on the spot. New calls: vehicle*,
and the VEHICLE_* constants; see Vehicles in the Physics chapter.
- Point lights inside geometry. A bulb in a closed room, under a shade
or inside a cabinet now shadows correctly: cube faces render depth
two-sided so closed meshes cast from inside, every shadow tap picks its
own cube face so the seams along the cube edges are gone, the depth
range fits the casters round the light instead of the scene centre,
the bias grows with distance and grazing angle, and each face draws
only what it can see. nodeSetShadow excuses a mesh from casting.
- Character controller. playerNew puts Jolt's CharacterVirtual on a
node, as a capsule or any convex bodyNew shape: it walks where
playerMove says, climbs steps, slides along walls, refuses steep
slopes, falls, jumps from the ground, rides kinematic platforms and
shoves light dynamic bodies; onCollision and onTrigger report it like
a body. Works in 2D worlds as a platformer character, with tolerances
scaled to the shape. New calls: player*; see Players in the Physics
chapter.
- Particles. Emitters spawn, move and age particles from a recipe (rate,
life, speed, direction and spread, gravity, drag, size, colour and spin
over life, a birth radius, a pool cap) in the overlay and in the 3D
scene alike. A 2D emitter draws on the GPU above or beneath the
overlay when the script asks (emitterDraw); a 3D emitter sits on a node
and draws camera-facing billboards after every mesh, depth tested, in
world units. Any loaded sprite is the picture (a random frame per
particle from an animated one); without one, a soft disc. Normal or
additive blending. New calls: emitter*, and the PARTICLE_* constants;
see the Particles chapter.
- 3D scenes. A game can draw a 3D scene between the disc video and the
overlay: primitive meshes and script-built geometry, materials with
colour, textures from sprites, the disc or a loaded video, metallic and
@ -42,8 +102,9 @@ API Changes
targets, placed any number of times. Everything is a node in one tree. New calls:
scene*, node*, mesh*, material*, light*, camera*, model*, animation*,
and the LIGHT_* constants; see the 3D Scenes chapter of the manual.
Needs a GPU with Vulkan, Direct3D 12 or Metal (Raspberry Pi 4 or
later); 2D games run as before without one. The renderer now runs on
Needs a GPU with Vulkan, Direct3D 12 or Metal; the Raspberry Pi 4 is
the minimum Pi (the Pi 3 has no Vulkan driver). 2D games run as
before without one. The renderer now runs on
SDL's GPU device where there is one.
- A game can be one file: singe --pack DIRECTORY GAME.game writes the game's

View file

@ -273,6 +273,8 @@ set(SINGE_SOURCE
src/main.h
src/pack.c
src/pack.h
src/particles.c
src/particles.h
src/physics.h
src/physicsJolt.cpp
src/model.c

View file

@ -106,7 +106,8 @@ release (lib/libdxcompiler.so, lib/libdxil.so, include/dxc/dxcapi.h) at
that path yourself, or build it from source there.
The Raspberry Pi build (64-bit Raspberry Pi OS, glibc 2.31 or newer)
also uses zig. The platform headers and libraries it links against are
runs on any 64-bit Pi; 3D games need a Pi 4 or later, the Pi 3 has no
Vulkan driver and plays 2D games only. The build also uses zig. The platform headers and libraries it links against are
Debian bookworm arm64 packages listed in cmake/zig/piPackages.cmake,
fetched from snapshot.debian.org and unpacked with dpkg-deb into
.builddir/toolchains/sysroot-aarch64-linux-gnu on first use:

View file

@ -46,5 +46,3 @@ Fonts
-----
FreeSansBold GPL-3.0 with font exception https://www.gnu.org/software/freefont
BreatheFire (design-time only: text layer in singeLogo.xcf and indexing.xcf;
the font file is not shipped and its license is not recorded)

View file

@ -3,7 +3,7 @@
# cmake -DSHADERCROSS=<tool> -DSOURCE=<scene.hlsl> -DOUTPUT=<sceneShaders.h> -P shaderHeader.cmake
# so the header is generated into the build tree like the icon and the other embedded files.
set(entries vertexStatic:vertex vertexSkinned:vertex fragmentMain:fragment depthMain:fragment)
set(entries vertexStatic:vertex vertexSkinned:vertex fragmentMain:fragment depthMain:fragment particleVertex:vertex particleFragment:fragment)
set(formats SPIRV DXIL MSL)
get_filename_component(sourceDir ${SOURCE} DIRECTORY)
get_filename_component(outputDir ${OUTPUT} DIRECTORY)

File diff suppressed because it is too large Load diff

View file

@ -1,476 +0,0 @@
dofile("Singe/Framework.singe")
stateStartup = 0
stateSetup = 1
stateTitle = 2
stateMenu = 3
stateIntro = 4
statePlaying = 5
stateGameOver = 6
gameStandard = 0
gameLimited = 1
pixelLow = 0
pixelHigh = 1
pixelUnknown = 2
sprLightOn = spriteLoad(DIR .. "sprite_LightOn.png")
sprLightOff = spriteLoad(DIR .. "sprite_LightOff.png")
sprActionMax = spriteLoad(DIR .. "sprite_ActionMax.png")
sprCrosshair = spriteLoad(DIR .. "sprite_Crosshair.png")
sprBullet = spriteLoad(DIR .. "sprite_Bullet.png")
sprBoxArt = spriteLoad(DIR .. "sprite_" .. gameID .. ".png")
sndActionMax = soundLoad(DIR .. "sound_ActionMax.wav")
sndSteadyAim = soundLoad(DIR .. "sound_ASteadyAimIsCritical.wav")
sndGetReady = soundLoad(DIR .. "sound_GetReadyForAction.wav")
sndGunShot = soundLoad(DIR .. "sound_Gunshot.wav")
sndGoodHit = soundLoad(DIR .. "sound_GoodHit.wav")
sndBadHit = soundLoad(DIR .. "sound_BadHit.wav")
sndGameOver = soundLoad(DIR .. "sound_GameOver.wav")
mouseX = 0
mouseY = 0
halfWidth = 0
crosshairCenterX = spriteGetWidth(sprCrosshair) / 2
crosshairCenterY = spriteGetHeight(sprCrosshair) / 2
currentState = stateStartup
gameMode = gameStandard
shotsFired = 0 -- Total Fired
shotsGood = 0 -- Hit our target
shotsBad = 0 -- Hit our friends
scoreDisplay = 0 -- How long the score is yet to be displayed
scoreTimer = 4 -- How long to display the score + 1
lightDisplay = 0 -- How long the light is yet to be displayed
lightTimer = 2 -- How long to display the light + 1
triggerPulled = 0 -- This isn't quite a boolean. It counts down so we can get multiple pixel samples across frames.
ammoLeft = 0 -- Number of misses remaining in limited ammo mode
ammoCount = 5 -- Number of misses allowed in limited ammo mode
gunLastState = pixelUnknown
sensorLastState = pixelUnknown
thisSeconds = 0
lastSeconds = 0
heartbeat = false
fntBlueStone20 = fontLoad(DIR .. "font_BlueStone.ttf", 20)
fntChemRea16 = fontLoad(DIR .. "font_chemrea.ttf", 16)
fntChemRea32 = fontLoad(DIR .. "font_chemrea.ttf", 32)
fntChemRea48 = fontLoad(DIR .. "font_chemrea.ttf", 48)
fntLEDReal32 = fontLoad(DIR .. "font_LED_Real.ttf", 32)
colorBackground(0, 0, 0, 0)
fontQuality(FONT_QUALITY_BLENDED)
colorForeground(255, 255, 0)
fontSelect(fntBlueStone20)
sprPullToStart = fontToSprite("Pull Trigger to Start!")
sprGetReady = fontToSprite("Get Ready!")
colorForeground(255, 255, 255)
fontSelect(fntChemRea16)
sprLastGame = fontToSprite("LAST GAME SCORE")
colorForeground(255, 255, 0)
fontSelect(fntChemRea32)
sprSelectGameType = fontToSprite("Select Game Type")
sprStandard1 = fontToSprite("Standard")
sprStandard2 = fontToSprite("Game")
sprLimited1 = fontToSprite("Limited")
sprLimited2 = fontToSprite("Ammo")
colorForeground(255, 0, 0)
fontSelect(fntChemRea48)
sprGameOver = fontToSprite("GAME OVER")
discSearch(backgroundFrame)
function getPixelState(intX, intY)
r1, g1, b1 = vldpGetPixel(intX, intY)
-- Determine pixel state
if ((255 - r1 < highThreshold) and (255 - g1 < highThreshold) and (255 - b1 < highThreshold)) then
pixelState = pixelHigh
elseif ((r1 < lowThreshold) and (g1 < lowThreshold) and (b1 < lowThreshold)) then
pixelState = pixelLow
else
pixelState = pixelUnknown
end
return pixelState
end
function onInputPressed(intWhat)
--dr, dg, db = vldpGetPixel(mouseX, mouseY)
--debugPrint("Current Frame: " .. discGetFrame() .. " X: " .. mouseX .. " Y: " .. mouseY .. " R: " .. dr .. " G: " .. dg .. " B: " .. db)
-- They fired!
if (intWhat == SWITCH_BUTTON3) then
if (currentState == stateTitle) then
discSearch(lengthIntro + lengthGame + 2)
discPlay()
currentState = stateMenu
elseif (currentState == stateMenu) then
if (mouseX < halfWidth) then
gameMode = gameStandard
else
ammoLeft = ammoCount
gameMode = gameLimited
end
shotsFired = 0
shotsGood = 0
shotsBad = 0
colorForeground(255, 0, 0)
fontSelect(fntLEDReal32)
discSearch(1)
discPlay()
soundPlay(sndGetReady)
currentState = stateIntro
elseif (currentState == statePlaying) then
-- Make gunshot noise
soundPlay(sndGunShot)
-- We want to sample across two frames
triggerPulled = 2
shotsFired = shotsFired + 1
-- No matter what, we subtract ammo. On good hits, we add it back to make it look like nothing changed.
if (gameMode == gameLimited) then
ammoLeft = ammoLeft - 1
end
end
end
end
function onMouseMoved(intX, intY, intXrel, intYrel)
-- Remember the mouse location for use later.
mouseX = intX
mouseY = intY
end
function onOverlayUpdate()
overlayClear()
currentFrame = discGetFrame()
-- Give us a 1 second heartbeat for overlay element timing
thisSeconds = os.time(os.date('*t'))
if (thisSeconds ~= lastSeconds) then
heartbeat = not heartbeat
lastSeconds = thisSeconds
-- Tick off floating score display
if (scoreDisplay > 0) then
scoreDisplay = scoreDisplay - 1
end
-- Tick off light timer
if (lightDisplay > 0) then
lightDisplay = lightDisplay - 1
end
end
if (currentState == stateStartup) then
-- We run this state to cause the overlay to update once before we use the dimensions to build the menu
overlayClear()
currentState = stateSetup
elseif (currentState == stateSetup) then
-- We do all the math to draw the menu here, one time.
halfWidth = (overlayGetWidth() / 2)
logoTop = 5
logoLeft = halfWidth - (spriteGetWidth(sprActionMax) / 2)
logoHeight = logoTop + spriteGetHeight(sprActionMax)
pullToStartHeight = spriteGetHeight(sprPullToStart)
pullToStartTop = overlayGetHeight() - pullToStartHeight
pullToStartLeft = halfWidth - (spriteGetWidth(sprPullToStart) / 2)
getReadyHeight = spriteGetHeight(sprGetReady)
getReadyTop = overlayGetHeight() - getReadyHeight
getReadyLeft = halfWidth - (spriteGetWidth(sprGetReady) / 2)
boxLeft = (halfWidth / 2) - spriteGetWidth(sprBoxArt) / 2 + halfWidth
boxTop = (overlayGetHeight() - (logoHeight + pullToStartHeight)) / 2 - spriteGetHeight(sprBoxArt) / 2 + logoHeight
lastGameLeft = (halfWidth / 2) - spriteGetWidth(sprLastGame) / 2
lastGameTop = logoHeight + logoTop
scoreHeight = 13 -- Point size + 1 instead of spriteGetHeight(sprLastGame)
scoreTop = lastGameTop + scoreHeight + 10
scoreLeft = lastGameLeft
lightLeft = overlayGetWidth() - spriteGetWidth(sprLightOff)
lightTop = overlayGetHeight() - spriteGetHeight(sprLightOff)
bulletWidth = spriteGetWidth(sprBullet)
selectGameTypeLeft = halfWidth - spriteGetWidth(sprSelectGameType) / 2
selectGameTypeTop = 25
standard1Left = halfWidth / 2 - spriteGetWidth(sprStandard1) / 2
standard2Left = halfWidth / 2 - spriteGetWidth(sprStandard2) / 2
limited1Left = halfWidth + (halfWidth / 2) - spriteGetWidth(sprLimited1) / 2
limited2Left = halfWidth + (halfWidth / 2) - spriteGetWidth(sprLimited2) / 2
standardLimited1Top = (overlayGetHeight() / 2) - spriteGetHeight(sprStandard1) + 2
standardLimited2Top = (overlayGetHeight() / 2) + 2
gameOverLeft = halfWidth - spriteGetWidth(sprGameOver) / 2
gameOverTop = (overlayGetHeight() / 2) - spriteGetHeight(sprGameOver)
colorForeground(200, 200, 200)
fontSelect(fntChemRea16)
hndActionMaxSound = soundPlay(sndActionMax)
currentState = stateTitle
elseif (currentState == stateTitle) then
spriteDraw(sprActionMax, logoLeft, logoTop)
spriteDraw(sprBoxArt, boxLeft, boxTop)
spriteDraw(sprLastGame, lastGameLeft, lastGameTop)
y = scoreTop
fontPrint(scoreLeft, y, " Shots Fired: " .. shotsFired)
y = y + scoreHeight
fontPrint(scoreLeft, y, " Good Hits: " .. shotsGood)
y = y + scoreHeight
fontPrint(scoreLeft, y, " Bad Hits: " .. shotsBad)
y = y + scoreHeight
fontPrint(scoreLeft, y, " Shot Score: " .. shotsGood - shotsBad)
y = y + scoreHeight * 2
if (shotsFired > 0) then
scorePercent = math.floor((shotsGood - shotsBad) / shotsFired * 100)
else
scorePercent = 0
end
fontPrint(scoreLeft, y, " Game Score: " .. scorePercent .. "%")
if (heartbeat) then
spriteDraw(sprPullToStart, pullToStartLeft, pullToStartTop)
end
elseif (currentState == stateMenu) then
spriteDraw(sprSelectGameType, selectGameTypeLeft, selectGameTypeTop)
spriteDraw(sprStandard1, standard1Left, standardLimited1Top)
spriteDraw(sprStandard2, standard2Left, standardLimited2Top)
spriteDraw(sprLimited1, limited1Left, standardLimited1Top)
spriteDraw(sprLimited2, limited2Left, standardLimited2Top)
if (currentFrame >= lengthIntro + lengthGame + lengthMenu) then
discSearch(lengthIntro + lengthGame + 2)
discPlay()
end
elseif (currentState == stateIntro) then
if (heartbeat) then
spriteDraw(sprGetReady, getReadyLeft, getReadyTop)
end
-- Skip into game video when the intro is over.
if (currentFrame >= lengthIntro) then
discSearch(lengthIntro + 1)
discPlay()
currentState = statePlaying
end
elseif (currentState == statePlaying) then
-- Read Sucton Cup Sensor.
sensorLastState = sensorState
sensorState = getPixelState(sensorX, sensorY)
-- Are they firing?
if (triggerPulled > 0) then
-- New frame (according to the sensor)?
if (sensorLastState ~= sensorState) then
-- Did they aim outside the suction cup sensor area?
if ((mouseX < sensorLeft) or (mouseY < sensorTop)) then
-- Yes. Process shot.
gunState = getPixelState(mouseX, mouseY)
else
-- No. They shot the sensor area. Bad user.
gunState = pixelUnknown
end
-- Are we doing the first or second frame sample?
if (triggerPulled == 1) then
--debugPrint("S1: " .. sensorLastState .. " S2: " .. sensorState .. " G1: " .. gunLastState .. " G2: " .. gunState)
-- Decide what happened. Do we have two good samples of each sensor?
if ((gunState ~= pixelUnknown) and (sensorState ~= pixelUnknown) and (gunLastState ~= pixelUnknown) and (sensorLastState ~= pixelUnknown)) then
-- If the sensor and gun states didn't change between frames, then this makes no sense.
if (gunLastState ~= gunState) then
-- Does the gun match the sensor?
if (gunState == sensorState) then
-- Yes! Hit bad guy!
soundPlay(sndGoodHit)
shotsGood = shotsGood + 1
lightDisplay = lightTimer
-- Add it back to make it look like nothing changed.
if (gameMode == gameLimited) then
ammoLeft = ammoLeft + 1
end
else
-- No. Crap - shot a good guy!
soundPlay(sndBadHit)
shotsBad = shotsBad + 1
end
-- Float the score for a bit
scoreDisplay = scoreTimer
end
end
else
-- Remember this info for next pass
gunLastState = gunState
end
-- Get ready for next pass
triggerPulled = triggerPulled - 1
end
end
-- When the game is over, return to the menu.
if (currentFrame >= lengthIntro + lengthGame) then
discPause()
discSearch(backgroundFrame)
colorForeground(255, 255, 255)
fontSelect(fntChemRea16)
currentState = stateTitle
end
-- Do we need to show the score?
if (scoreDisplay > 0) then
fontPrint(5, 5, "SCORE: " .. (shotsGood - shotsBad))
end
-- Do we need to light the light?
if (lightDisplay > 0) then
spriteDraw(sprLightOn, lightLeft, lightTop)
else
spriteDraw(sprLightOff, lightLeft, lightTop)
end
-- Do we need to draw the ammo display?
if (gameMode == gameLimited) then
-- Are they out of ammo?
if (ammoLeft < 0) then
discSearch(lengthIntro + lengthGame + 2)
discPlay()
soundPlay(sndGameOver)
currentState = stateGameOver
end
if (ammoLeft > 0) then
bulletStart = overlayGetWidth() - bulletWidth - 5
for i=1,ammoLeft do
spriteDraw(sprBullet, bulletStart, 0)
bulletStart = bulletStart - bulletWidth
end
end
end
elseif (currentState == stateGameOver) then
spriteDraw(sprGameOver, gameOverLeft, gameOverTop)
if (currentFrame >= lengthIntro + lengthGame + lengthMenu) then
discPause()
discSearch(backgroundFrame)
colorForeground(255, 255, 255)
fontSelect(fntChemRea16)
currentState = stateTitle
end
end
-- Draw gun crosshair (This must be the last thing we draw so it's on top.)
if (singeWantsCrosshairs()) then
spriteDraw(sprCrosshair, mouseX - crosshairCenterX, mouseY - crosshairCenterY)
end
return(OVERLAY_UPDATED)
end
function onShutdown()
discStop()
-- Unload our resources.
fontUnload(fntBlueStone20)
fontUnload(fntChemRea16)
fontUnload(fntChemRea32)
fontUnload(fntChemRea48)
fontUnload(fntLEDReal32)
soundUnload(sndActionMax)
soundUnload(sndSteadyAim)
soundUnload(sndGetReady)
soundUnload(sndGunShot)
soundUnload(sndGoodHit)
soundUnload(sndBadHit)
soundUnload(sndGameOver)
spriteUnload(sprLightOn)
spriteUnload(sprLightOff)
spriteUnload(sprActionMax)
spriteUnload(sprCrosshair)
spriteUnload(sprBullet)
spriteUnload(sprBoxArt)
spriteUnload(sprPullToStart)
spriteUnload(sprGetReady)
spriteUnload(sprLastGame)
spriteUnload(sprSelectGameType)
spriteUnload(sprStandard1)
spriteUnload(sprStandard2)
spriteUnload(sprLimited1)
spriteUnload(sprLimited2)
spriteUnload(sprGameOver)
end
function onSoundCompleted(intWhich)
-- Play the "A Steady Aim is Critical" sound after "ActionMax" is finished.
if (intWhich == hndActionMaxSound) then
soundPlay(sndSteadyAim)
hndActionMaxSound = SOUND_REMOVE_HANDLE
end
end

View file

@ -1,26 +0,0 @@
Game Patches
============
These are corrected copies of scripts from third party Singe games. They
are not part of the engine build; each one replaces the same named file
inside an installed game. Package one as a ".patch" archive (see
"Packaging Your Game" in the manual) or copy the file over the original.
ActionMax/Emulator.singe
Shared emulator script used by every ActionMax title. Fixes sprite
leaks in the original release and uses the Singe 3.00 sprite argument
order. The per-game wrapper script must define gameID, backgroundFrame,
lengthIntro, lengthGame, lengthMenu, highThreshold, lowThreshold,
sensorX, sensorY, sensorLeft, and sensorTop before loading it.
The ActionMax games.dat is not part of this repository. Since Singe
3.00 every laserdisc entry in it needs one added line, next to VIDEO:
DISC = true,
Entries without it are skipped by the menu with a message, and an entry
that names a VIDEO without DISC = true is refused by the engine.
daitarn_3_singe/Script/toolbox.singe
Helper library from "Daitarn 3" (Karis, 2020). The calling script must
define OVLW, OVLH, and bPause.

View file

@ -1,330 +0,0 @@
--[[
PROGRAM NAME: LUA SINGE
VERSION: 1.1
AUTHOR: KARIS (2020)
This file is part of LUA SINGE.
LUA SINGE is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation.
LUA SINGE is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Thanks to Scott Duensing, RDG.
]]--
iSecs = 0
iLimit = 0
lastSeconds = 0
thisSeconds = 0
sprText = nil
sprShadow = nil
sLastText = nil
iLastColor = -1
iLastShadow = -1
tSecs = 0
tLimit = 0
tlastSeconds = 0
tthisSeconds = 0
bTommy = false
bGunMute = false
iMuteFrames = 0
MUTE_DELAY = 35
heartbeat = false
blinkSecs = 0
lastBlinkSecs = 0
iRevFrames = 0
REV_DELAY = 10
bReversePointer = false
revsetx = 0; revsety = 0
iFrameStart = 0; iFrameEnd = 0
CHANNEL_LEFT = 1
CHANNEL_RIGHT = 2
ALL_CHANNELS = 3
bMuteAttract = false
RED = 0
BLUE = 1
YELLOW = 2
GREEN = 3
ORANGE = 4
WHITE = 5
GREY = 6
GRAY = 6
PINK = 7
LIGHTBLUE = 7
BLACK = 8
function singeRandomize()
math.randomseed(os.time()) -- random initialize
math.random(); math.random(); math.random() -- warming up
end
function blinkTimer(thisMS)
-- Function blinks every second.
blinkSecs = os.clock()
if bPause then
lastBlinkSecs = blinkSecs
else
if (blinkSecs - lastBlinkSecs > thisMS) then
heartbeat = not heartbeat
lastBlinkSecs = blinkSecs
end
end
end
function goTimer(thisMS)
blinkSecs = os.clock()
if bPause then
lastBlinkSecs = blinkSecs
else
if (blinkSecs - lastBlinkSecs > thisMS) then
heartbeat = true
lastBlinkSecs = blinkSecs
end
end
end
function clockRnd()
local j = 0
local q = 0
local w = 0
local r = 0
local b1 = true
j = os.clock()
q, w = math.modf(j)
s2 = tostring(w)
r = string.find(s2, ".", 1, true)
if (r == nil) then
s2 = tostring(q)
s2 = string.sub(s2,string.len(s2), string.len(s2))
else
s2 = string.sub(s2, r + 1)
r = string.len(s2)
if r == 0 then
s2 = tostring(q)
s2 = string.sub(s2,string.len(s2), string.len(s2))
elseif r == 2 then
s2 = string.sub(s2, 2, 2)
elseif r >= 3 then
s2 = string.sub(s2, 3, 3)
end
end
w = tonumber(s2)
return w
end
function timerOFF()
iSecs = 0
iLimit = 0
end
function timerON(thisLong)
iSecs = 0
iLimit = thisLong
lastSeconds = os.clock()
end
function timerDue()
thisSeconds = os.clock()
if bPause then
lastSeconds = thisSeconds
else
if (thisSeconds ~= lastSeconds) then
iSecs = iSecs + thisSeconds - lastSeconds
lastSeconds = thisSeconds
end
if (iSecs >= iLimit) then
timerOFF()
return true
else
return false
end
end
end
function muteSound()
iMuteFrames = 0
bGunMute = true
end
function blinkRev()
iRevFrames = 0
bReversePointer = true
end
function setupClip(thisA, thisB)
iFrameStart = thisA
iFrameEnd = thisB
discSkipToFrame(thisA)
end
function monoAudio (thisChannel)
if thisChannel == CHANNEL_LEFT then
discAudio (2, false)
discAudio (1, true)
elseif thisChannel == CHANNEL_RIGHT then
discAudio (1, false)
discAudio (2, true)
end
end
function resetChannels()
discAudio(1, true)
discAudio(2, true)
end
function muteAudio()
discAudio(1, false)
discAudio(2, false)
end
function setFontColor(thisColor)
if thisColor == RED then
colorForeground(255, 0, 0)
elseif thisColor == BLUE then
colorForeground(0, 0, 255)
elseif thisColor == YELLOW then
colorForeground(255, 255, 0)
elseif thisColor == GREEN then
colorForeground(0, 255, 0)
elseif thisColor == ORANGE then
colorForeground(255, 150, 0)
elseif thisColor == WHITE then
colorForeground(255, 255, 255)
elseif thisColor == GREY or thisColor == GRAY then
colorForeground(128, 128, 128)
elseif thisColor == LIGHTBLUE then
colorForeground(30, 160, 250)
elseif thisColor == BLACK then
colorForeground(0,0,0)
elseif thisColor == PINK then
colorForeground(252,0,148)
end
end
function textPrint(thisMsg, thisx, thisy, thisFont, thisColor, thisShadow)
fontSelect(thisFont)
setFontColor(thisColor)
fontPrint(thisx,thisy,thisMsg)
end
function getMiddle(thisPhrase)
local sprite = fontToSprite(thisPhrase)
local x = OVLW/2 - spriteGetWidth(sprite) * 0.5
spriteUnload(sprite)
return x
end

784
src/particles.c Normal file
View file

@ -0,0 +1,784 @@
/*
* Singe - Particles: emitters that spawn, move and age particles for the 2D overlay and the 3D scene.
* Copyright (C) 2026 Scott Duensing
*
* See LICENSE for details.
*
* One simulation serves both worlds. A 2D emitter keeps its own position in overlay pixels; a 3D
* emitter is born at a node's world position. Particles live in structure-of-arrays pools, packed
* so the live ones are always the first "count" entries; a dead one is swapped with the last. The
* renderers (the overlay in singe.c, the scene in scene.c) read the pools through EmitterViewT.
*/
#include <math.h>
#include <string.h>
#include "particles.h"
#include "scene.h"
#include "../thirdparty/uthash/src/uthash.h"
#include "util.h"
#define DEFAULT_MAX 1000
#define MAX_STEP_SECONDS 0.1
#define DISC_SIZE 64
#define QUEUE_MAX 256
#define DEGREES_TO_RADIANS 0.017453292519943295f
typedef struct EmitterS {
int32_t id;
int32_t node; // -1: 2D
// The recipe
float rate;
float lifeMin;
float lifeMax;
float speedMin;
float speedMax;
Vec3T direction;
float spread; // Degrees
Vec3T gravity;
float drag;
float sizeStart;
float sizeEnd;
float sizeVariation;
float colour0[4];
float colour1[4];
float spinMin;
float spinMax;
float radius;
bool local;
int32_t max;
ParticleBlendE blend;
ParticleLayerE layer;
int32_t frameFirst;
int32_t frameLast;
SDL_Surface **frames;
int32_t frameCount;
uint32_t textureVersion;
// The state
bool emitting;
float pending; // Fractional particles owed
Vec3T position; // 2D position, or the last known 3D origin
int32_t count;
float *px;
float *py;
float *pz;
float *vx;
float *vy;
float *vz;
float *age;
float *life;
float *size0;
float *size1;
float *angle;
float *spin;
int32_t *frame;
ParticleViewT *views;
UT_hash_handle hh;
} EmitterT;
static void _allocatePools(EmitterT *emitter, int32_t max);
static EmitterT *_find(int32_t emitter);
static void _freeFrames(EmitterT *emitter);
static void _freePools(EmitterT *emitter);
static void _kill(EmitterT *emitter, int32_t index);
static Vec3T _origin(EmitterT *emitter);
static float _random(float min, float max);
static Vec3T _randomDirection(EmitterT *emitter);
static Vec3T _randomOffset(EmitterT *emitter);
static void _spawn(EmitterT *emitter, int32_t count);
static void _step(EmitterT *emitter, float dt);
static void _view(EmitterT *emitter, EmitterViewT *view);
static EmitterT *_emitters = NULL;
static int32_t _nextId = 1;
static uint64_t _lastTick = 0;
static SDL_Surface *_disc = NULL;
static int32_t _queue[QUEUE_MAX];
static int32_t _queueCount = 0;
static void _allocatePools(EmitterT *emitter, int32_t max) {
_freePools(emitter);
emitter->max = max;
emitter->count = 0;
emitter->px = SDL_calloc((size_t)max, sizeof(float));
emitter->py = SDL_calloc((size_t)max, sizeof(float));
emitter->pz = SDL_calloc((size_t)max, sizeof(float));
emitter->vx = SDL_calloc((size_t)max, sizeof(float));
emitter->vy = SDL_calloc((size_t)max, sizeof(float));
emitter->vz = SDL_calloc((size_t)max, sizeof(float));
emitter->age = SDL_calloc((size_t)max, sizeof(float));
emitter->life = SDL_calloc((size_t)max, sizeof(float));
emitter->size0 = SDL_calloc((size_t)max, sizeof(float));
emitter->size1 = SDL_calloc((size_t)max, sizeof(float));
emitter->angle = SDL_calloc((size_t)max, sizeof(float));
emitter->spin = SDL_calloc((size_t)max, sizeof(float));
emitter->frame = SDL_calloc((size_t)max, sizeof(int32_t));
emitter->views = SDL_calloc((size_t)max, sizeof(ParticleViewT));
if ((emitter->px == NULL) || (emitter->py == NULL) || (emitter->pz == NULL) || (emitter->vx == NULL) || (emitter->vy == NULL) || (emitter->vz == NULL) || (emitter->age == NULL) || (emitter->life == NULL) || (emitter->size0 == NULL) || (emitter->size1 == NULL) || (emitter->angle == NULL) || (emitter->spin == NULL) || (emitter->frame == NULL) || (emitter->views == NULL)) {
utilDie("Out of memory for %d particles.", max);
}
}
static EmitterT *_find(int32_t emitter) {
EmitterT *found = NULL;
HASH_FIND_INT(_emitters, &emitter, found);
return found;
}
static void _freeFrames(EmitterT *emitter) {
int32_t x = 0;
for (x = 0; x < emitter->frameCount; x++) {
SDL_DestroySurface(emitter->frames[x]);
}
SDL_free(emitter->frames);
emitter->frames = NULL;
emitter->frameCount = 0;
}
static void _freePools(EmitterT *emitter) {
SDL_free(emitter->px);
SDL_free(emitter->py);
SDL_free(emitter->pz);
SDL_free(emitter->vx);
SDL_free(emitter->vy);
SDL_free(emitter->vz);
SDL_free(emitter->age);
SDL_free(emitter->life);
SDL_free(emitter->size0);
SDL_free(emitter->size1);
SDL_free(emitter->angle);
SDL_free(emitter->spin);
SDL_free(emitter->frame);
SDL_free(emitter->views);
emitter->px = NULL;
emitter->py = NULL;
emitter->pz = NULL;
emitter->vx = NULL;
emitter->vy = NULL;
emitter->vz = NULL;
emitter->age = NULL;
emitter->life = NULL;
emitter->size0 = NULL;
emitter->size1 = NULL;
emitter->angle = NULL;
emitter->spin = NULL;
emitter->frame = NULL;
emitter->views = NULL;
emitter->count = 0;
}
// Removes particle index by moving the last live one into its slot.
static void _kill(EmitterT *emitter, int32_t index) {
int32_t last = emitter->count - 1;
if (index != last) {
emitter->px[index] = emitter->px[last];
emitter->py[index] = emitter->py[last];
emitter->pz[index] = emitter->pz[last];
emitter->vx[index] = emitter->vx[last];
emitter->vy[index] = emitter->vy[last];
emitter->vz[index] = emitter->vz[last];
emitter->age[index] = emitter->age[last];
emitter->life[index] = emitter->life[last];
emitter->size0[index] = emitter->size0[last];
emitter->size1[index] = emitter->size1[last];
emitter->angle[index] = emitter->angle[last];
emitter->spin[index] = emitter->spin[last];
emitter->frame[index] = emitter->frame[last];
}
emitter->count = last;
}
// Where particles are born: the 2D position, or the node's world position.
static Vec3T _origin(EmitterT *emitter) {
if (emitter->node >= 0) {
if (nodeValid(emitter->node)) {
emitter->position = nodeGetWorldPosition(emitter->node);
}
}
return emitter->position;
}
static float _random(float min, float max) {
return min + (max - min) * SDL_randf();
}
// A unit vector within the spread cone round the direction (in 2D the cone is a fan in the plane).
static Vec3T _randomDirection(EmitterT *emitter) {
Vec3T axis = emitter->direction;
Vec3T other;
Vec3T side;
Vec3T up;
float length = vec3Length(axis);
float cosMax = SDL_cosf(emitter->spread * DEGREES_TO_RADIANS);
float cosA;
float sinA;
float phi;
if (length < 1e-6f) {
axis = vec3(0.0f, -1.0f, 0.0f);
} else {
axis = vec3Scale(axis, 1.0f / length);
}
if (emitter->node < 0) {
// 2D: rotate within the plane by a random angle up to the spread.
phi = _random(-emitter->spread, emitter->spread) * DEGREES_TO_RADIANS;
return vec3(axis.x * SDL_cosf(phi) - axis.y * SDL_sinf(phi), axis.x * SDL_sinf(phi) + axis.y * SDL_cosf(phi), 0.0f);
}
// 3D: uniform over the spherical cap.
cosA = _random(cosMax, 1.0f);
sinA = SDL_sqrtf(SDL_max(0.0f, 1.0f - cosA * cosA));
phi = _random(0.0f, 2.0f * SDL_PI_F);
other = (SDL_fabsf(axis.y) < 0.9f) ? vec3(0.0f, 1.0f, 0.0f) : vec3(1.0f, 0.0f, 0.0f);
side = vec3Normalize(vec3Cross(axis, other));
up = vec3Cross(side, axis);
return vec3Add(vec3Scale(axis, cosA), vec3Add(vec3Scale(side, sinA * SDL_cosf(phi)), vec3Scale(up, sinA * SDL_sinf(phi))));
}
// A random point in the birth disc (2D) or ball (3D) round the origin.
static Vec3T _randomOffset(EmitterT *emitter) {
Vec3T point;
if (emitter->radius <= 0.0f) {
return vec3(0.0f, 0.0f, 0.0f);
}
// Rejection sampling keeps it uniform.
do {
point = vec3(_random(-1.0f, 1.0f), _random(-1.0f, 1.0f), (emitter->node < 0) ? 0.0f : _random(-1.0f, 1.0f));
} while (vec3Dot(point, point) > 1.0f);
return vec3Scale(point, emitter->radius);
}
static void _spawn(EmitterT *emitter, int32_t count) {
Vec3T origin = _origin(emitter);
Vec3T base = emitter->local ? vec3(0.0f, 0.0f, 0.0f) : origin;
Vec3T offset;
Vec3T velocity;
float variation;
int32_t i;
while ((count > 0) && (emitter->count < emitter->max)) {
i = emitter->count++;
offset = vec3Add(base, _randomOffset(emitter));
velocity = vec3Scale(_randomDirection(emitter), _random(emitter->speedMin, emitter->speedMax));
variation = 1.0f + _random(-emitter->sizeVariation, emitter->sizeVariation);
emitter->px[i] = offset.x;
emitter->py[i] = offset.y;
emitter->pz[i] = offset.z;
emitter->vx[i] = velocity.x;
emitter->vy[i] = velocity.y;
emitter->vz[i] = velocity.z;
emitter->age[i] = 0.0f;
emitter->life[i] = SDL_max(0.001f, _random(emitter->lifeMin, emitter->lifeMax));
emitter->size0[i] = emitter->sizeStart * variation;
emitter->size1[i] = emitter->sizeEnd * variation;
emitter->angle[i] = _random(0.0f, 360.0f);
emitter->spin[i] = _random(emitter->spinMin, emitter->spinMax);
emitter->frame[i] = (emitter->frameCount > 1) ? (int32_t)_random((float)emitter->frameFirst, (float)emitter->frameLast + 0.999f) : 0;
count--;
}
}
static void _step(EmitterT *emitter, float dt) {
float keep = SDL_max(0.0f, 1.0f - emitter->drag * dt);
int32_t i = 0;
if (emitter->emitting) {
emitter->pending += emitter->rate * dt;
if (emitter->pending >= 1.0f) {
_spawn(emitter, (int32_t)emitter->pending);
emitter->pending -= (float)(int32_t)emitter->pending;
}
}
while (i < emitter->count) {
emitter->age[i] += dt;
if (emitter->age[i] >= emitter->life[i]) {
_kill(emitter, i);
continue;
}
emitter->vx[i] = (emitter->vx[i] + emitter->gravity.x * dt) * keep;
emitter->vy[i] = (emitter->vy[i] + emitter->gravity.y * dt) * keep;
emitter->vz[i] = (emitter->vz[i] + emitter->gravity.z * dt) * keep;
emitter->px[i] += emitter->vx[i] * dt;
emitter->py[i] += emitter->vy[i] * dt;
emitter->pz[i] += emitter->vz[i] * dt;
emitter->angle[i] += emitter->spin[i] * dt;
i++;
}
}
static void _view(EmitterT *emitter, EmitterViewT *view) {
Vec3T base = emitter->local ? _origin(emitter) : vec3(0.0f, 0.0f, 0.0f);
float t;
int32_t i;
int32_t c;
for (i = 0; i < emitter->count; i++) {
t = emitter->age[i] / emitter->life[i];
emitter->views[i].position = vec3Add(base, vec3(emitter->px[i], emitter->py[i], emitter->pz[i]));
emitter->views[i].size = emitter->size0[i] + (emitter->size1[i] - emitter->size0[i]) * t;
emitter->views[i].angle = emitter->angle[i];
emitter->views[i].frame = emitter->frame[i];
for (c = 0; c < 4; c++) {
emitter->views[i].colour[c] = emitter->colour0[c] + (emitter->colour1[c] - emitter->colour0[c]) * t;
}
}
view->id = emitter->id;
view->node = emitter->node;
view->blend = emitter->blend;
view->layer = emitter->layer;
view->frameCount = (emitter->frameCount > 0) ? emitter->frameCount : 1;
view->frames = (emitter->frameCount > 0) ? emitter->frames : &_disc;
view->textureVersion = emitter->textureVersion;
view->count = emitter->count;
view->particles = emitter->views;
}
void emitterBurst(int32_t emitter, int32_t count) {
EmitterT *found = _find(emitter);
if (found != NULL) {
_spawn(found, count);
}
}
void emitterClear(int32_t emitter) {
EmitterT *found = _find(emitter);
if (found != NULL) {
found->count = 0;
found->pending = 0.0f;
}
}
void emitterDelete(int32_t emitter) {
EmitterT *found = _find(emitter);
if (found != NULL) {
HASH_DEL(_emitters, found);
_freePools(found);
_freeFrames(found);
SDL_free(found);
}
}
int32_t emitterGetCount(int32_t emitter) {
EmitterT *found = _find(emitter);
return (found != NULL) ? found->count : 0;
}
bool emitterIs3D(int32_t emitter) {
EmitterT *found = _find(emitter);
return (found != NULL) && (found->node >= 0);
}
bool emitterIsActive(int32_t emitter) {
EmitterT *found = _find(emitter);
return (found != NULL) && (found->emitting || (found->count > 0));
}
int32_t emitterNew(int32_t node) {
EmitterT *emitter = SDL_calloc(1, sizeof(EmitterT));
if (emitter == NULL) {
utilDie("Out of memory for an emitter.");
}
emitter->id = _nextId++;
emitter->node = node;
emitter->rate = 50.0f;
emitter->lifeMin = 1.0f;
emitter->lifeMax = 2.0f;
emitter->speedMin = (node < 0) ? 50.0f : 1.0f;
emitter->speedMax = (node < 0) ? 100.0f : 2.0f;
emitter->direction = (node < 0) ? vec3(0.0f, -1.0f, 0.0f) : vec3(0.0f, 1.0f, 0.0f);
emitter->spread = 30.0f;
emitter->sizeStart = (node < 0) ? 16.0f : 0.2f;
emitter->sizeEnd = (node < 0) ? 4.0f : 0.05f;
emitter->colour0[0] = 1.0f;
emitter->colour0[1] = 1.0f;
emitter->colour0[2] = 1.0f;
emitter->colour0[3] = 1.0f;
emitter->colour1[0] = 1.0f;
emitter->colour1[1] = 1.0f;
emitter->colour1[2] = 1.0f;
emitter->colour1[3] = 0.0f;
emitter->textureVersion = 1;
_allocatePools(emitter, DEFAULT_MAX);
HASH_ADD_INT(_emitters, id, emitter);
return emitter->id;
}
void emitterSetBlend(int32_t emitter, ParticleBlendE blend) {
EmitterT *found = _find(emitter);
if (found != NULL) {
found->blend = blend;
}
}
void emitterSetColor(int32_t emitter, const float *start, const float *finish) {
EmitterT *found = _find(emitter);
if (found != NULL) {
memcpy(found->colour0, start, sizeof(found->colour0));
memcpy(found->colour1, finish, sizeof(found->colour1));
}
}
void emitterSetDirection(int32_t emitter, Vec3T direction) {
EmitterT *found = _find(emitter);
if (found != NULL) {
found->direction = direction;
}
}
void emitterSetDrag(int32_t emitter, float perSecond) {
EmitterT *found = _find(emitter);
if (found != NULL) {
found->drag = SDL_max(0.0f, perSecond);
}
}
void emitterSetFrames(int32_t emitter, int32_t first, int32_t last) {
EmitterT *found = _find(emitter);
if (found != NULL) {
found->frameFirst = SDL_clamp(first, 0, SDL_max(0, found->frameCount - 1));
found->frameLast = SDL_clamp(last, found->frameFirst, SDL_max(0, found->frameCount - 1));
}
}
void emitterSetGravity(int32_t emitter, Vec3T acceleration) {
EmitterT *found = _find(emitter);
if (found != NULL) {
found->gravity = acceleration;
}
}
void emitterSetLayer(int32_t emitter, ParticleLayerE layer) {
EmitterT *found = _find(emitter);
if (found != NULL) {
found->layer = layer;
}
}
void emitterSetLife(int32_t emitter, float minSeconds, float maxSeconds) {
EmitterT *found = _find(emitter);
if (found != NULL) {
found->lifeMin = SDL_max(0.001f, minSeconds);
found->lifeMax = SDL_max(found->lifeMin, maxSeconds);
}
}
void emitterSetLocal(int32_t emitter, bool local) {
EmitterT *found = _find(emitter);
if ((found != NULL) && (found->local != local)) {
// Keep the live particles where they are by moving them between frames of reference.
Vec3T origin = _origin(found);
float sign = local ? -1.0f : 1.0f;
int32_t i;
for (i = 0; i < found->count; i++) {
found->px[i] += sign * origin.x;
found->py[i] += sign * origin.y;
found->pz[i] += sign * origin.z;
}
found->local = local;
}
}
void emitterSetMax(int32_t emitter, int32_t count) {
EmitterT *found = _find(emitter);
if ((found != NULL) && (count > 0) && (count != found->max)) {
_allocatePools(found, count);
}
}
void emitterSetPosition(int32_t emitter, Vec3T position) {
EmitterT *found = _find(emitter);
if (found != NULL) {
found->position = position;
}
}
void emitterSetRadius(int32_t emitter, float radius) {
EmitterT *found = _find(emitter);
if (found != NULL) {
found->radius = SDL_max(0.0f, radius);
}
}
void emitterSetRate(int32_t emitter, float perSecond) {
EmitterT *found = _find(emitter);
if (found != NULL) {
found->rate = SDL_max(0.0f, perSecond);
}
}
void emitterSetSize(int32_t emitter, float start, float finish, float variation) {
EmitterT *found = _find(emitter);
if (found != NULL) {
found->sizeStart = SDL_max(0.0f, start);
found->sizeEnd = SDL_max(0.0f, finish);
found->sizeVariation = SDL_clamp(variation, 0.0f, 1.0f);
}
}
void emitterSetSpeed(int32_t emitter, float min, float max) {
EmitterT *found = _find(emitter);
if (found != NULL) {
found->speedMin = min;
found->speedMax = SDL_max(min, max);
}
}
void emitterSetSpin(int32_t emitter, float min, float max) {
EmitterT *found = _find(emitter);
if (found != NULL) {
found->spinMin = min;
found->spinMax = SDL_max(min, max);
}
}
void emitterSetSpread(int32_t emitter, float degrees) {
EmitterT *found = _find(emitter);
if (found != NULL) {
found->spread = SDL_clamp(degrees, 0.0f, 180.0f);
}
}
void emitterSetTexture(int32_t emitter, SDL_Surface **frames, int32_t frameCount) {
EmitterT *found = _find(emitter);
int32_t x = 0;
if (found == NULL) {
return;
}
_freeFrames(found);
if ((frames != NULL) && (frameCount > 0)) {
found->frames = SDL_calloc((size_t)frameCount, sizeof(SDL_Surface *));
if (found->frames == NULL) {
utilDie("Out of memory for particle frames.");
}
for (x = 0; x < frameCount; x++) {
found->frames[x] = SDL_DuplicateSurface(frames[x]);
if (found->frames[x] == NULL) {
utilDie("%s", SDL_GetError());
}
}
found->frameCount = frameCount;
}
found->frameFirst = 0;
found->frameLast = 0;
found->textureVersion++;
for (x = 0; x < found->count; x++) {
found->frame[x] = 0;
}
}
void emitterStart(int32_t emitter) {
EmitterT *found = _find(emitter);
if (found != NULL) {
found->emitting = true;
}
}
void emitterStop(int32_t emitter) {
EmitterT *found = _find(emitter);
if (found != NULL) {
found->emitting = false;
found->pending = 0.0f;
}
}
bool emitterValid(int32_t emitter) {
return _find(emitter) != NULL;
}
void particlesClearQueue2D(void) {
_queueCount = 0;
}
int32_t particlesCount(void) {
return (int32_t)HASH_COUNT(_emitters);
}
// The built-in picture: a soft disc, opaque in the middle fading to nothing at the edge.
void particlesInit(void) {
int32_t x;
int32_t y;
float dx;
float dy;
float d;
uint8_t alpha;
uint32_t *pixels;
_disc = SDL_CreateSurface(DISC_SIZE, DISC_SIZE, SDL_PIXELFORMAT_RGBA32);
if (_disc == NULL) {
utilDie("%s", SDL_GetError());
}
pixels = (uint32_t *)_disc->pixels;
for (y = 0; y < DISC_SIZE; y++) {
for (x = 0; x < DISC_SIZE; x++) {
dx = ((float)x + 0.5f) / (DISC_SIZE / 2.0f) - 1.0f;
dy = ((float)y + 0.5f) / (DISC_SIZE / 2.0f) - 1.0f;
d = SDL_sqrtf(dx * dx + dy * dy);
alpha = (uint8_t)(255.0f * SDL_clamp(1.0f - d, 0.0f, 1.0f) * SDL_clamp(1.0f - d, 0.0f, 1.0f));
pixels[y * (_disc->pitch / 4) + x] = SDL_MapSurfaceRGBA(_disc, 255, 255, 255, alpha);
}
}
_lastTick = 0;
}
void particlesQueue2D(int32_t emitter) {
if ((_queueCount < QUEUE_MAX) && emitterValid(emitter)) {
_queue[_queueCount++] = emitter;
}
}
int32_t particlesQueued2D(ParticleLayerE layer, int32_t *emitters, int32_t max) {
EmitterT *found;
int32_t x;
int32_t n = 0;
for (x = 0; (x < _queueCount) && (n < max); x++) {
found = _find(_queue[x]);
if ((found != NULL) && (found->layer == layer)) {
emitters[n++] = _queue[x];
}
}
return n;
}
void particlesQuit(void) {
EmitterT *emitter;
EmitterT *next;
HASH_ITER(hh, _emitters, emitter, next) {
emitterDelete(emitter->id);
}
if (_disc != NULL) {
SDL_DestroySurface(_disc);
_disc = NULL;
}
_queueCount = 0;
}
// Advances every emitter by the wall-clock time since the last call; a paused game passes no time.
void particlesUpdate(bool advance) {
EmitterT *emitter;
EmitterT *next;
uint64_t now = SDL_GetTicksNS();
double dt = 0.0;
if (advance && (_lastTick != 0)) {
dt = (double)(now - _lastTick) / 1e9;
}
_lastTick = now;
if (dt > MAX_STEP_SECONDS) {
dt = MAX_STEP_SECONDS;
}
HASH_ITER(hh, _emitters, emitter, next) {
if ((emitter->node >= 0) && !nodeValid(emitter->node)) {
emitterDelete(emitter->id);
continue;
}
if (dt > 0.0) {
_step(emitter, (float)dt);
}
}
}
bool particlesView(int32_t index, EmitterViewT *view) {
EmitterT *emitter;
EmitterT *next;
int32_t n = 0;
HASH_ITER(hh, _emitters, emitter, next) {
if (n == index) {
_view(emitter, view);
return true;
}
n++;
}
return false;
}
bool particlesViewEmitter(int32_t emitter, EmitterViewT *view) {
EmitterT *found = _find(emitter);
if (found == NULL) {
return false;
}
_view(found, view);
return true;
}

87
src/particles.h Normal file
View file

@ -0,0 +1,87 @@
/*
* Singe - Particles: emitters that spawn, move and age particles for the 2D overlay and the 3D scene.
* Copyright (C) 2026 Scott Duensing
*
* See LICENSE for details.
*/
#ifndef PARTICLES_H
#define PARTICLES_H
#include <stdbool.h>
#include <stdint.h>
#include <SDL3/SDL.h>
#include "math3d.h"
typedef enum ParticleBlendE {
PARTICLE_ALPHA = 0, // Normal alpha blending
PARTICLE_ADD = 1 // Additive: light on light
} ParticleBlendE;
typedef enum ParticleLayerE {
PARTICLE_OVER = 0, // 2D: drawn above the overlay
PARTICLE_UNDER = 1 // 2D: drawn beneath the overlay, above the video and the scene
} ParticleLayerE;
// One particle, as handed to a renderer: world or overlay position, size, rotation, tint and frame.
typedef struct ParticleViewT {
Vec3T position;
float size;
float angle; // Degrees
float colour[4]; // 0..1
int32_t frame;
} ParticleViewT;
// Renderers ask an emitter for its live particles; the texture per frame is theirs to keep.
typedef struct EmitterViewT {
int32_t id;
int32_t node; // -1 for a 2D emitter
ParticleBlendE blend;
ParticleLayerE layer;
int32_t frameCount;
SDL_Surface **frames; // frameCount surfaces (the built-in disc when no sprite is set)
uint32_t textureVersion; // Bumped whenever the frames change
int32_t count;
ParticleViewT *particles; // count entries, valid until the next update
} EmitterViewT;
void particlesInit(void);
void particlesQuit(void);
void particlesUpdate(bool advance);
int32_t particlesCount(void);
bool particlesView(int32_t index, EmitterViewT *view); // Emitter number index of particlesCount
bool particlesViewEmitter(int32_t emitter, EmitterViewT *view);
void particlesQueue2D(int32_t emitter); // Draw this emitter this frame
int32_t particlesQueued2D(ParticleLayerE layer, int32_t *emitters, int32_t max);
void particlesClearQueue2D(void);
int32_t emitterNew(int32_t node);
bool emitterValid(int32_t emitter);
void emitterDelete(int32_t emitter);
void emitterSetTexture(int32_t emitter, SDL_Surface **frames, int32_t frameCount); // Copies the surfaces; NULL restores the disc
void emitterSetFrames(int32_t emitter, int32_t first, int32_t last);
void emitterSetBlend(int32_t emitter, ParticleBlendE blend);
void emitterSetLayer(int32_t emitter, ParticleLayerE layer);
void emitterSetRate(int32_t emitter, float perSecond);
void emitterSetLife(int32_t emitter, float minSeconds, float maxSeconds);
void emitterSetSpeed(int32_t emitter, float min, float max);
void emitterSetDirection(int32_t emitter, Vec3T direction);
void emitterSetSpread(int32_t emitter, float degrees);
void emitterSetGravity(int32_t emitter, Vec3T acceleration);
void emitterSetDrag(int32_t emitter, float perSecond);
void emitterSetSize(int32_t emitter, float start, float finish, float variation);
void emitterSetColor(int32_t emitter, const float *start, const float *finish);
void emitterSetSpin(int32_t emitter, float min, float max);
void emitterSetRadius(int32_t emitter, float radius);
void emitterSetLocal(int32_t emitter, bool local);
void emitterSetMax(int32_t emitter, int32_t count);
void emitterSetPosition(int32_t emitter, Vec3T position);
void emitterStart(int32_t emitter);
void emitterStop(int32_t emitter);
void emitterBurst(int32_t emitter, int32_t count);
void emitterClear(int32_t emitter);
int32_t emitterGetCount(int32_t emitter);
bool emitterIsActive(int32_t emitter);
bool emitterIs3D(int32_t emitter);
#endif

View file

@ -76,6 +76,21 @@ typedef enum JointTypeE {
} JointTypeE;
typedef enum SoftKindE {
SOFT_CLOTH = 0, // An open mesh: a flag, a curtain, a sheet
SOFT_BODY = 1, // A closed mesh under pressure: a balloon, a jelly
SOFT_ROPE = 2 // A chain of points with a tube drawn round it
} SoftKindE;
typedef enum VehicleKindE {
VEHICLE_CAR = 0,
VEHICLE_MOTORCYCLE = 1,
VEHICLE_TANK = 2,
VEHICLE_BOAT = 3
} VehicleKindE;
bool bodyApplyForce(int32_t node, Vec3T force, const Vec3T *at);
bool bodyApplyImpulse(int32_t node, Vec3T impulse, const Vec3T *at);
bool bodyDelete(int32_t node);
@ -86,16 +101,76 @@ bool bodyIsResting(int32_t node);
bool bodyNew(int32_t node, BodyTypeE type, ShapeTypeE shape, float a, float b, float c);
bool bodySetAngularVelocity(int32_t node, Vec3T velocity);
bool bodySetBounce(int32_t node, float bounce);
bool bodySetBuoyancy(int32_t node, float factor);
bool bodySetCurrent(int32_t node, Vec3T flow);
bool bodySetEnabled(int32_t node, bool enabled);
bool bodySetFriction(int32_t node, float friction);
bool bodySetMass(int32_t node, float kilograms);
bool bodySetTrigger(int32_t node, bool trigger);
bool bodySetVelocity(int32_t node, Vec3T velocity);
bool bodySetWater(int32_t node, float density, float linearDrag, float angularDrag);
bool jointDelete(int32_t joint);
int32_t jointNew(JointTypeE type, int32_t nodeA, int32_t nodeB, Vec3T anchor, Vec3T axis);
bool jointSetLimits(int32_t joint, float low, float high);
bool jointValid(int32_t joint);
bool physicsAvailable(void);
bool playerDelete(int32_t node);
bool playerExists(int32_t node);
int32_t playerGetGround(int32_t node, Vec3T *normal);
Vec3T playerGetVelocity(int32_t node);
bool playerIsOnGround(int32_t node);
bool playerIsSwimming(int32_t node);
bool playerJump(int32_t node, float speed);
bool playerMove(int32_t node, Vec3T velocity);
bool playerNew(int32_t node, ShapeTypeE shape, float a, float b, float c);
bool playerSetEnabled(int32_t node, bool enabled);
bool playerSetGravityScale(int32_t node, float scale);
bool playerSetMass(int32_t node, float kilograms);
bool playerSetPosition(int32_t node, Vec3T position);
bool playerSetPush(int32_t node, float strength);
bool playerSetSlope(int32_t node, float degrees);
bool playerSetStep(int32_t node, float height);
bool playerSetSwim(int32_t node, float sinkSpeed, float drag);
bool playerSetVelocity(int32_t node, Vec3T velocity);
bool ragdollActivate(int32_t node);
bool ragdollApplyImpulse(int32_t node, const char *joint, Vec3T impulse);
bool ragdollDeactivate(int32_t node);
bool ragdollDelete(int32_t node);
bool ragdollExists(int32_t node);
bool ragdollIsActive(int32_t node);
bool ragdollIsResting(int32_t node);
bool ragdollNew(int32_t node);
bool ragdollSetJoint(int32_t node, const char *joint, float radius, float swingDegrees, float twistDegrees);
bool ragdollSetStrength(int32_t node, float strength);
bool softDelete(int32_t node);
bool softExists(int32_t node);
bool softNew(int32_t node, SoftKindE kind);
bool softNewRope(int32_t node, Vec3T end, int32_t segments, float radius);
bool softPin(int32_t node, Vec3T point, int32_t follow);
bool softSetDamping(int32_t node, float damping);
bool softSetMass(int32_t node, float kilograms);
bool softSetPressure(int32_t node, float pressure);
bool softSetStiffness(int32_t node, float stretch, float bend);
bool softUnpin(int32_t node, Vec3T point);
int32_t vehicleAddWheel(int32_t node, int32_t wheelNode, float radius, float width, float suspension);
bool vehicleDelete(int32_t node);
bool vehicleDrive(int32_t node, float forward, float right, float brake, float handBrake);
bool vehicleExists(int32_t node);
int32_t vehicleGetGear(int32_t node);
float vehicleGetRpm(int32_t node);
float vehicleGetSpeed(int32_t node);
float vehicleGetWheelSlip(int32_t node, int32_t index);
bool vehicleIsWheelOnGround(int32_t node, int32_t index);
bool vehicleNew(int32_t node, VehicleKindE kind);
bool vehicleSetAntiRoll(int32_t node, float stiffness);
bool vehicleSetBrakes(int32_t node, float brake, float handBrake);
bool vehicleSetEngine(int32_t node, float maxTorque, float maxRpm, float minRpm);
bool vehicleSetGears(int32_t node, const float *ratios, int32_t count, float reverse, bool automatic);
bool vehicleSetSteering(int32_t node, float maxDegrees);
bool vehicleSetRudder(int32_t node, float maxTorque);
bool vehicleSetSuspension(int32_t node, float frequency, float damping);
bool vehicleSetThrust(int32_t node, float maxForce, Vec3T point);
bool vehicleSetWheel(int32_t node, int32_t index, bool steered, bool driven);
int32_t physicsGetEvents(PhysicsEventT *events, int32_t maximum);
bool physicsInit(void);
void physicsQuit(void);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -97,12 +97,15 @@ int32_t meshBox(float width, float height, float depth);
int32_t meshCone(float radius, float height, int32_t segments);
int32_t meshCylinder(float radius, float height, int32_t segments);
bool meshDelete(int32_t mesh);
int32_t nodeGetMaterial(int32_t node);
bool meshSetPositions(int32_t mesh, const float *positions);
bool meshGetGeometry(int32_t mesh, const float **positions, int32_t *vertexCount, const uint32_t **indices, int32_t *indexCount);
int32_t meshFindMorph(int32_t mesh, const char *name);
int32_t meshGetMorphCount(int32_t mesh);
const char *meshGetMorphName(int32_t mesh, int32_t target);
int32_t meshNew(const float *positions, const float *normals, const float *uvs, int32_t vertexCount, const uint32_t *indices, int32_t indexCount);
int32_t meshNewVertices(const SceneVertexT *vertices, int32_t vertexCount, const uint32_t *indices, int32_t indexCount, bool skinned);
int32_t meshGrid(float width, float depth, int32_t columns, int32_t rows);
int32_t meshPlane(float width, float depth);
int32_t meshSphere(float radius, int32_t segments);
int32_t meshTorus(float radius, float tubeRadius, int32_t segments);
@ -128,6 +131,7 @@ bool nodeLookAt(int32_t node, Vec3T target);
bool nodeMove(int32_t node, Vec3T delta);
int32_t nodeNew(int32_t parent);
bool nodeRotate(int32_t node, QuatT delta);
bool nodeSetMaterial(int32_t node, int32_t material);
bool nodeSetMesh(int32_t node, int32_t mesh, int32_t material);
bool nodeSetMorphWeight(int32_t node, int32_t target, float weight);
bool nodeSetName(int32_t node, const char *name);
@ -135,7 +139,9 @@ bool nodeSetParent(int32_t node, int32_t parent);
bool nodeSetPosition(int32_t node, Vec3T position);
bool nodeSetRotation(int32_t node, QuatT rotation);
bool nodeSetScale(int32_t node, Vec3T scale);
int32_t nodeGetSkinJoints(int32_t node, const int32_t **joints);
bool nodeSetSkin(int32_t node, const int32_t *joints, const Mat4T *inverseBind, int32_t count);
bool nodeSetShadow(int32_t node, bool casts);
bool nodeSetVisible(int32_t node, bool visible);
bool nodeSetWorldTransform(int32_t node, Vec3T position, QuatT rotation);
bool nodeValid(int32_t node);

View file

@ -177,13 +177,11 @@ float shadowFactorMap(int slot, float3 worldPosition, float3 normal, float3 toLi
// major axis and looked up with the same frame the engine rendered it with (forward, up, right =
// forward x up, a 90 degree perspective), so no cube-map convention is involved; the point's own
// projected depth is compared over five taps.
float shadowFactorCube(int slot, float3 worldPosition, float3 normal, float3 lightPosition) {
float base = shadowInfo[slot].y;
float near = shadowInfo[slot].z;
float far = shadowInfo[slot].w;
float3 dir = (worldPosition + normal * shadowParams.x * 8.0) - lightPosition;
// One tap of a point light's shadow: the face is picked from the direction's major axis and the
// same frame the engine rendered it with is rebuilt, so a tap that crosses a face edge reads the
// neighbouring face instead of the wrong texels.
float cubeTap(float base, float near, float far, float bias, float3 dir) {
float3 ad = abs(dir);
float texel = shadowParams.y;
float ma;
float face;
float3 forward;
@ -191,7 +189,6 @@ float shadowFactorCube(int slot, float3 worldPosition, float3 normal, float3 lig
float3 right;
float2 uv;
float depth;
float lit;
if ((ad.x >= ad.y) && (ad.x >= ad.z)) {
ma = ad.x;
@ -211,15 +208,39 @@ float shadowFactorCube(int slot, float3 worldPosition, float3 normal, float3 lig
}
right = cross(forward, up);
uv = float2(0.5 + 0.5 * dot(dir, right) / ma, 0.5 - 0.5 * dot(dir, up) / ma);
depth = far / (far - near) - near * far / ((far - near) * ma) - shadowParams.x * 2.0;
depth = far / (far - near) - near * far / ((far - near) * ma) - bias;
if (depth > 1.0) {
return 1.0;
}
lit = (shadowMaps.Sample(shadowSampler, float3(uv, base + face)).r >= depth) ? 1.0 : 0.0;
lit += (shadowMaps.Sample(shadowSampler, float3(uv + float2(texel, 0.0), base + face)).r >= depth) ? 1.0 : 0.0;
lit += (shadowMaps.Sample(shadowSampler, float3(uv - float2(texel, 0.0), base + face)).r >= depth) ? 1.0 : 0.0;
lit += (shadowMaps.Sample(shadowSampler, float3(uv + float2(0.0, texel), base + face)).r >= depth) ? 1.0 : 0.0;
lit += (shadowMaps.Sample(shadowSampler, float3(uv - float2(0.0, texel), base + face)).r >= depth) ? 1.0 : 0.0;
return (shadowMaps.Sample(shadowSampler, float3(uv, base + face)).r >= depth) ? 1.0 : 0.0;
}
// How lit a point is by a point light's six shadow faces: five taps, each choosing its own face.
// The normal offset grows with distance (a face's texel covers more world the farther it is) and
// the depth bias with the grazing angle, so surfaces near the bulb stay acne-free.
float shadowFactorCube(int slot, float3 worldPosition, float3 normal, float3 lightPosition) {
float base = shadowInfo[slot].y;
float near = shadowInfo[slot].z;
float far = shadowInfo[slot].w;
float texel = shadowParams.y;
float3 toPoint = worldPosition - lightPosition;
float distance = length(toPoint);
float3 toLight = -toPoint / max(distance, 0.0001);
float grazing = 1.0 - saturate(dot(normal, toLight));
float texelWorld = 2.0 * distance * texel;
float3 dir = toPoint + normal * texelWorld * (1.5 + 2.0 * grazing);
float bias = shadowParams.x * (1.0 + 3.0 * grazing);
float3 axisA = normalize(cross(dir, (abs(dir.y) < 0.9) ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0)));
float3 axisB = normalize(cross(dir, axisA));
float step = texelWorld;
float lit;
lit = cubeTap(base, near, far, bias, dir);
lit += cubeTap(base, near, far, bias, dir + axisA * step);
lit += cubeTap(base, near, far, bias, dir - axisA * step);
lit += cubeTap(base, near, far, bias, dir + axisB * step);
lit += cubeTap(base, near, far, bias, dir - axisB * step);
return lit / 5.0;
}
@ -304,3 +325,48 @@ float4 fragmentMain(VertexOutput input) : SV_Target {
}
return float4(result, albedo.a);
}
// ---- Particles: camera-facing billboards, unlit, one texture per draw ----
cbuffer ParticleUniforms : register(b0, space1) {
float4x4 particleViewProjection;
float4 cameraRight;
float4 cameraUp;
};
struct ParticleInput {
float3 centre : TEXCOORD0;
float2 corner : TEXCOORD1; // -1..1 across the quad
float2 sizeAngle : TEXCOORD2; // x = size (world units), y = rotation in degrees
float4 colour : TEXCOORD3;
float2 uv : TEXCOORD4;
};
struct ParticleOutput {
float4 position : SV_Position;
float4 colour : TEXCOORD0;
float2 uv : TEXCOORD1;
};
Texture2D<float4> particleTexture : register(t0, space2);
SamplerState particleSampler : register(s0, space2);
ParticleOutput particleVertex(ParticleInput input) {
ParticleOutput output;
float radians = input.sizeAngle.y * 0.017453292;
float c = cos(radians);
float s = sin(radians);
float2 offset = float2(input.corner.x * c - input.corner.y * s, input.corner.x * s + input.corner.y * c) * input.sizeAngle.x * 0.5;
float3 world = input.centre + cameraRight.xyz * offset.x + cameraUp.xyz * offset.y;
output.position = mul(particleViewProjection, float4(world, 1.0));
output.colour = input.colour;
output.uv = input.uv;
return output;
}
float4 particleFragment(ParticleOutput input) : SV_Target {
return particleTexture.Sample(particleSampler, input.uv) * input.colour;
}

File diff suppressed because it is too large Load diff

BIN
testScripts/Models/Sponza.glb (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -2,14 +2,14 @@
The Lua scripts used to verify the 3D scene and physics work, one per stage
of PLAN.md sections 18 and 19, with everything they load: the Khronos
Duck, Fox, BoxAnimated and AnimatedMorphCube sample models under Models/, Box.png and crate.png. The
Duck, Fox, BoxAnimated, AnimatedMorphCube and Sponza sample models under Models/, Box.png and crate.png. The
disc and the font are the engine's own, addressed as
Singe/menuBackground.mkv and Singe/FreeSansBold.ttf like any engine
asset. This directory is a complete Singe game directory. Run one from the singe directory with
.builddir/Singe-v3.00-Linux-x86_64 -w -d data -v Singe/menuBackground.mkv testScripts/scene6.singe
(scene12 has no disc: give it -C 720x480 instead of -v)
(scene12 and scene20 have no disc: give them -C 720x480 instead of -v)
or pack it (singe --pack testScripts testScripts.game) and run an entry
with --entry N (the numbers follow games.dat). Every script takes a
@ -33,3 +33,14 @@ screenshot or two and quits by itself; the results are in screenshots/.
| scene14.singe | 14 | - | The solid tangram dragon (Models/DragonModel.glb, a copy of assets/DragonModel.glb) turning under a sun with shadows |
| scene15.singe | 15 | - | The Singe logotype as solid letters (Models/SingeText.glb, a copy of assets/SingeText.glb) turning under a sun with shadows |
| scene16.singe | 16 | - | The dragon perched on the logotype, both models under one parent node, turning together |
| scene17.singe | 17 | 20.1 | 2D particles: fireworks, a smoke trail, sparks off a 2D physics ball; runs with SDL_GPU_DRIVER=nothing |
| scene18.singe | 18 | 20.2 | 3D particles: torch flame and smoke on a post, a fountain, dust, over the disc with shadows |
| scene19.singe | 19 | 21.1 | Character controller: the Fox on a course of stairs, a ramp, a jump, a crate, a wall and a steep slope |
| scene20.singe | 20 | 21.2 | Character controller in 2D: a sprite platformer (gap, step, wall, ledge); runs with SDL_GPU_DRIVER=nothing and -C 720x480 |
| scene21.singe | 21 | 22.1 | Point-light shadows inside geometry: a bulb under a shade and a bulb in a cage, in a closed room of outward-facing boxes, inside and outside views |
| scene22.singe | 22 | 23.1 | Vehicles: a car from primitives on a ramp track, driven by a scripted route with a chase camera |
| scene23.singe | 23 | 23.2 | Vehicles: a tank that pivots and climbs, and a motorcycle that leans through a turn |
| scene24.singe | 24 | 24.1 | Water: a pool and a river; crates float, sink and drift, the Fox swims across, a raft drives |
| scene25.singe | 25 | 25.1 | Ragdolls: a running Fox crumples at a wall, one falls down stairs, one lies under motor strength |
| scene26.singe | 26 | 26.1 | Soft bodies: a flag, a sheet catching a crate, a swinging rope, a balloon |
| scene27.singe | 27 | - | The Khronos Sponza atrium (Models/Sponza.glb, 52 MB, packed with util/packGlb.py) under a sun with shadows, six braziers with flame, smoke and dust particles; no disc, -C 1280x720 |

View file

@ -174,4 +174,123 @@ GAMES = {
DEVELOPER = "Test",
PUBLISHER = "Test",
},
{
TITLE = "Particles2D",
SCRIPT = "testScripts/scene17.singe",
VIDEO = "Singe/menuBackground.mkv",
DESCRIPTION = "2D particles: fireworks, a smoke trail and sparks off a physics ball; runs with no GPU.",
YEAR = 2026,
GENRE = "Test",
PLATFORM = "Singe",
DEVELOPER = "Test",
PUBLISHER = "Test",
},
{
TITLE = "Particles3D",
SCRIPT = "testScripts/scene18.singe",
VIDEO = "Singe/menuBackground.mkv",
DESCRIPTION = "3D particles: a torch flame and smoke on a post, a fountain, dust in the air.",
YEAR = 2026,
GENRE = "Test",
PLATFORM = "Singe",
DEVELOPER = "Test",
PUBLISHER = "Test",
},
{
TITLE = "Player",
SCRIPT = "testScripts/scene19.singe",
VIDEO = "Singe/menuBackground.mkv",
DESCRIPTION = "Character controller: the Fox walks stairs and a ramp, jumps, shoves a crate and stops at a steep slope.",
YEAR = 2026,
GENRE = "Test",
PLATFORM = "Singe",
DEVELOPER = "Test",
PUBLISHER = "Test",
},
{
TITLE = "Platformer",
SCRIPT = "testScripts/scene20.singe",
DESCRIPTION = "Character controller in 2D: a sprite platformer with a gap, a step, a wall and a ledge; no GPU needed.",
YEAR = 2026,
GENRE = "Test",
PLATFORM = "Singe",
DEVELOPER = "Test",
PUBLISHER = "Test",
},
{
TITLE = "BulbInRoom",
SCRIPT = "testScripts/scene21.singe",
VIDEO = "Singe/menuBackground.mkv",
DESCRIPTION = "Point-light shadows inside geometry: a bulb under a shade and a bulb in a cage, in a closed room with pillars, seen inside and out.",
YEAR = 2026,
GENRE = "Test",
PLATFORM = "Singe",
DEVELOPER = "Test",
PUBLISHER = "Test",
},
{
TITLE = "Car",
SCRIPT = "testScripts/scene22.singe",
VIDEO = "Singe/menuBackground.mkv",
DESCRIPTION = "Vehicles: a car from primitives on a scripted route over a ramp, braking, turning and reversing.",
YEAR = 2026,
GENRE = "Test",
PLATFORM = "Singe",
DEVELOPER = "Test",
PUBLISHER = "Test",
},
{
TITLE = "TankAndBike",
SCRIPT = "testScripts/scene23.singe",
VIDEO = "Singe/menuBackground.mkv",
DESCRIPTION = "Vehicles: a six-wheeled tank that pivots on the spot and a motorcycle that leans through a turn.",
YEAR = 2026,
GENRE = "Test",
PLATFORM = "Singe",
DEVELOPER = "Test",
PUBLISHER = "Test",
},
{
TITLE = "Water",
SCRIPT = "testScripts/scene24.singe",
VIDEO = "Singe/menuBackground.mkv",
DESCRIPTION = "Water: a pool and a river with a current; floating, sinking and drifting bodies, the Fox swimming, a raft under power.",
YEAR = 2026,
GENRE = "Test",
PLATFORM = "Singe",
DEVELOPER = "Test",
PUBLISHER = "Test",
},
{
TITLE = "Ragdolls",
SCRIPT = "testScripts/scene25.singe",
VIDEO = "Singe/menuBackground.mkv",
DESCRIPTION = "Ragdolls: a running Fox crumples against a wall, another falls down stairs limp, a third lies under motor strength.",
YEAR = 2026,
GENRE = "Test",
PLATFORM = "Singe",
DEVELOPER = "Test",
PUBLISHER = "Test",
},
{
TITLE = "SoftBodies",
SCRIPT = "testScripts/scene26.singe",
VIDEO = "Singe/menuBackground.mkv",
DESCRIPTION = "Soft bodies: a flag on a pole, a sheet catching a crate, a swinging rope and a balloon under pressure.",
YEAR = 2026,
GENRE = "Test",
PLATFORM = "Singe",
DEVELOPER = "Test",
PUBLISHER = "Test",
},
{
TITLE = "Sponza",
SCRIPT = "testScripts/scene27.singe",
DESCRIPTION = "The Khronos Sponza atrium under a sun with shadows, on a camera walk.",
YEAR = 2026,
GENRE = "Test",
PLATFORM = "Singe",
DEVELOPER = "Test",
PUBLISHER = "Test",
},
}

88
testScripts/scene17.singe Normal file
View file

@ -0,0 +1,88 @@
-- Particles, 2D: fireworks bursting over the disc, a smoke trail following a moving point, and
-- sparks off a bouncing 2D physics ball, all drawn with the overlay. Runs without a GPU too.
local font = fontLoad("Singe/FreeSansBold.ttf", 28)
local frames = 0
local width = overlayGetWidth()
local height = overlayGetHeight()
fontSelect(font)
discPlay()
-- Fireworks: a burst of bright additive sparks that fall and fade.
local fireworks = emitterNew()
emitterSetBlend(fireworks, PARTICLE_ADD)
emitterSetLife(fireworks, 0.8, 1.6)
emitterSetSpeed(fireworks, 120, 320)
emitterSetSpread(fireworks, 180)
emitterSetGravity(fireworks, 0, 220)
emitterSetDrag(fireworks, 0.8)
emitterSetSize(fireworks, 14, 3, 0.3)
emitterSetColor(fireworks, 255, 230, 120, 255, 255, 60, 20, 0)
emitterSetMax(fireworks, 2000)
-- Smoke: grey puffs drifting up from a moving point, drawn under the overlay text.
local smoke = emitterNew()
emitterSetLayer(smoke, PARTICLE_UNDER)
emitterSetRate(smoke, 90)
emitterSetLife(smoke, 1.2, 2.0)
emitterSetSpeed(smoke, 30, 60)
emitterSetDirection(smoke, 0, -1)
emitterSetSpread(smoke, 25)
emitterSetGravity(smoke, 0, -20)
emitterSetSize(smoke, 12, 48, 0.2)
emitterSetColor(smoke, 200, 200, 210, 160, 90, 90, 100, 0)
emitterSetSpin(smoke, -60, 60)
emitterSetRadius(smoke, 6)
emitterStart(smoke)
-- Sparks: short hot streaks thrown off a ball bouncing in a 2D physics world.
physicsSet2D(true)
physicsSetGravity(0, 600, 0)
local floor = nodeNew()
nodeSetPosition(floor, width / 2, height - 10, 0)
bodyNew(floor, BODY_STATIC, SHAPE_BOX, width, 20, 50)
local ball = nodeNew()
nodeSetPosition(ball, width * 0.25, 80, 0)
bodyNew(ball, BODY_DYNAMIC, SHAPE_SPHERE, 18)
bodySetBounce(ball, 0.8)
bodySetVelocity(ball, 140, 0, 0)
local sparks = emitterNew()
emitterSetBlend(sparks, PARTICLE_ADD)
emitterSetRate(sparks, 160)
emitterSetLife(sparks, 0.3, 0.7)
emitterSetSpeed(sparks, 80, 200)
emitterSetDirection(sparks, 0, -1)
emitterSetSpread(sparks, 70)
emitterSetGravity(sparks, 0, 500)
emitterSetSize(sparks, 5, 1)
emitterSetColor(sparks, 255, 255, 200, 255, 255, 120, 0, 0)
emitterStart(sparks)
local ballSprite = spriteLoad("testScripts/crate.png")
function onOverlayUpdate()
local t = frames / 60
local bx = nodeGetPosition(ball)
local by = select(2, nodeGetPosition(ball))
frames = frames + 1
overlayClear()
-- The smoke source circles slowly.
emitterSetPosition(smoke, width / 2 + 160 * math.cos(t), height / 2 + 60 * math.sin(t * 1.3))
-- Sparks come from wherever the ball is.
emitterSetPosition(sparks, bx, by + 14)
if frames % 45 == 1 then
emitterSetPosition(fireworks, 120 + (frames * 7) % (width - 240), 80 + (frames * 13) % 160)
emitterBurst(fireworks, 260)
end
emitterDraw(smoke)
emitterDraw(fireworks)
emitterDraw(sparks)
spriteDraw(ballSprite, bx - 18, by - 18, bx + 18, by + 18)
fontPrint(20, 20, "Particles 2D, frame " .. frames .. " live " .. emitterGetCount(fireworks) + emitterGetCount(smoke) + emitterGetCount(sparks))
if frames == 40 or frames == 110 or frames == 170 then
singeScreenshot()
end
if frames == 180 then
singeQuit()
end
end

108
testScripts/scene18.singe Normal file
View file

@ -0,0 +1,108 @@
-- Particles, 3D: a torch flame on a post, a fountain, and dust drifting in a sunbeam over the disc,
-- with a crate to show the billboards depth-testing against geometry and casting no shadow.
local font = fontLoad("Singe/FreeSansBold.ttf", 28)
local frames = 0
fontSelect(font)
discPlay()
sceneEnable(true)
sceneSetBackground(0, 0, 0, 0)
sceneSetAmbient(50, 50, 60)
local stone = materialNew()
materialSetColor(stone, 110, 110, 120)
materialSetRoughness(stone, 0.9)
local wood = materialNew()
materialSetColor(wood, 140, 90, 50)
local floor = nodeNew()
nodeSetMesh(floor, meshBox(12, 0.2, 8), stone)
nodeSetPosition(floor, 0, -1.1, 0)
local post = nodeNew()
nodeSetMesh(post, meshCylinder(0.08, 2.2, 12), wood)
nodeSetPosition(post, -2.5, 0, 0)
local crate = nodeNew()
nodeSetMesh(crate, meshBox(1, 1, 1), wood)
nodeSetPosition(crate, 0.8, -0.5, 0.5)
-- The flame: additive, born in a small ball at the top of the post, rising and shrinking.
local torchTip = nodeNew()
nodeSetParent(torchTip, post)
nodeSetPosition(torchTip, 0, 1.15, 0)
local flame = emitterNew(torchTip)
emitterSetBlend(flame, PARTICLE_ADD)
emitterSetRate(flame, 140)
emitterSetLife(flame, 0.4, 0.9)
emitterSetSpeed(flame, 0.6, 1.4)
emitterSetDirection(flame, 0, 1, 0)
emitterSetSpread(flame, 15)
emitterSetSize(flame, 0.35, 0.05, 0.3)
emitterSetColor(flame, 255, 200, 90, 255, 255, 40, 0, 0)
emitterSetRadius(flame, 0.06)
emitterSetSpin(flame, -90, 90)
emitterStart(flame)
-- Smoke above the flame, alpha blended and slow.
local smoke = emitterNew(torchTip)
emitterSetRate(smoke, 25)
emitterSetLife(smoke, 1.5, 2.5)
emitterSetSpeed(smoke, 0.5, 0.9)
emitterSetDirection(smoke, 0.2, 1, 0)
emitterSetSpread(smoke, 20)
emitterSetSize(smoke, 0.2, 0.9, 0.2)
emitterSetColor(smoke, 80, 80, 90, 120, 60, 60, 70, 0)
emitterSetSpin(smoke, -30, 30)
emitterStart(smoke)
-- A fountain: water drops thrown up and falling under gravity, staying where they were born.
local spout = nodeNew()
nodeSetPosition(spout, 2.5, -1, -0.5)
local fountain = emitterNew(spout)
emitterSetRate(fountain, 300)
emitterSetLife(fountain, 1.0, 1.6)
emitterSetSpeed(fountain, 4.0, 5.0)
emitterSetDirection(fountain, 0, 1, 0)
emitterSetSpread(fountain, 8)
emitterSetGravity(fountain, 0, -9.8, 0)
emitterSetSize(fountain, 0.08, 0.12)
emitterSetColor(fountain, 150, 200, 255, 220, 150, 200, 255, 0)
emitterSetMax(fountain, 1500)
emitterStart(fountain)
-- Dust: a few slow motes in a box of air, following nothing.
local air = nodeNew()
nodeSetPosition(air, 0, 0.5, 0)
local dust = emitterNew(air)
emitterSetRate(dust, 20)
emitterSetLife(dust, 4, 6)
emitterSetSpeed(dust, 0.05, 0.2)
emitterSetSpread(dust, 180)
emitterSetSize(dust, 0.04, 0.04)
emitterSetColor(dust, 255, 240, 200, 0, 255, 240, 200, 160)
emitterSetRadius(dust, 2.5)
emitterSetMax(dust, 200)
emitterStart(dust)
emitterBurst(dust, 120)
local sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(sun, -3, 6, 4)
nodeLookAt(sun, 0, 0, 0)
lightSetIntensity(sun, 1.4)
lightSetShadow(sun, true)
local camera = nodeNew()
nodeSetPosition(camera, 0, 1.2, 7)
nodeLookAt(camera, 0, 0, 0)
cameraSet(camera)
function onOverlayUpdate()
frames = frames + 1
nodeRotate(post, 0, 0.5, 0)
overlayClear()
fontPrint(20, 20, "Particles 3D, frame " .. frames .. " flame " .. emitterGetCount(flame) .. " fountain " .. emitterGetCount(fountain))
if frames == 60 or frames == 120 or frames == 170 then
singeScreenshot()
end
if frames == 180 then
singeQuit()
end
end

121
testScripts/scene19.singe Normal file
View file

@ -0,0 +1,121 @@
-- Character controller: the Fox as a player walking a course of stairs, ramps, a wall, a crate to
-- shove and a moving platform, driven by a scripted route so it runs headless; the camera follows.
local font = fontLoad("Singe/FreeSansBold.ttf", 28)
local frames = 0
fontSelect(font)
discPlay()
sceneEnable(true)
sceneSetBackground(0, 0, 0, 0)
sceneSetAmbient(70, 70, 80)
local stone = materialNew()
materialSetColor(stone, 120, 120, 130)
materialSetRoughness(stone, 0.9)
local wood = materialNew()
materialSetColor(wood, 150, 100, 50)
local steel = materialNew()
materialSetColor(steel, 90, 110, 150)
materialSetMetallic(steel, 0.6)
local function block(x, y, z, w, h, d, material)
local node = nodeNew()
nodeSetMesh(node, meshBox(w, h, d), material)
nodeSetPosition(node, x, y, z)
bodyNew(node, BODY_STATIC, SHAPE_BOX, w, h, d)
return node
end
-- The course runs along +X. Floor, three stairs, a gentle ramp, a steep ramp, a wall to slide along.
block(8, -0.1, 0, 30, 0.2, 8, stone)
block(2.0, 0.075, 0, 0.6, 0.15, 4, wood)
block(2.6, 0.15, 0, 0.6, 0.30, 4, wood)
block(3.2, 0.225, 0, 0.6, 0.45, 4, wood)
local ramp = nodeNew()
nodeSetMesh(ramp, meshBox(3, 0.2, 4), stone)
nodeSetPosition(ramp, 6.2, 0.7, 0)
nodeSetRotation(ramp, 0, 0, 25)
bodyNew(ramp, BODY_STATIC, SHAPE_BOX, 3, 0.2, 4)
block(9.0, 0.7, 0, 2.6, 2.6, 4, stone) -- The top landing behind the ramp
local steep = nodeNew()
nodeSetMesh(steep, meshBox(2, 0.2, 4), stone)
nodeSetPosition(steep, 11.0, 2.85, 0)
nodeSetRotation(steep, 0, 0, 60)
bodyNew(steep, BODY_STATIC, SHAPE_BOX, 2, 0.2, 4)
block(9.0, 2.4, -2.5, 4, 1.2, 0.2, steel) -- A wall along the landing's far edge
-- A crate to shove and a platform that slides back and forth.
local crate = nodeNew()
nodeSetMesh(crate, meshBox(0.6, 0.6, 0.6), wood)
nodeSetPosition(crate, 4.6, 0.3, 0.15)
bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.6, 0.6, 0.6)
bodySetMass(crate, 5)
local platform = nodeNew()
nodeSetMesh(platform, meshBox(1.5, 0.2, 1.5), steel)
nodeSetPosition(platform, 8.5, 2.1, 1.5)
bodyNew(platform, BODY_KINEMATIC, SHAPE_BOX, 1.5, 0.2, 1.5)
-- The player: a capsule with the Fox model hanging off the node, facing +X.
local player = nodeNew()
nodeSetPosition(player, 0, 0.05, 0)
playerNew(player, 0.3, 1.0)
playerSetStep(player, 0.35)
local foxModel = modelLoad("testScripts/Models/Fox.glb")
local fox = modelInstance(foxModel)
nodeSetParent(fox, player)
nodeSetScale(fox, 0.01)
nodeSetRotation(fox, 0, 90, 0)
animationPlay(fox, "Walk", true)
local sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(sun, -3, 8, 5)
nodeLookAt(sun, 6, 0, 0)
lightSetIntensity(sun, 1.5)
lightSetShadow(sun, true)
local camera = nodeNew()
cameraSet(camera)
local jumped = false
local landings = 0
function onCollision(a, b, x, y, z, speed)
if a == player or b == player then
landings = landings + 1
end
end
function onOverlayUpdate()
local px, py, pz = nodeGetPosition(player)
local t = frames / 60
frames = frames + 1
-- Route: walk east up the stairs and the ramp, veer to the wall, then onto the platform.
local vz = 0
if px > 7.5 and px < 10 then
vz = (t < 6) and -1.2 or 1.2
end
playerMove(player, 3.0, vz)
if px > 5.5 and not jumped and playerIsOnGround(player) then
jumped = playerJump(player, 4.5)
end
nodeSetRotation(player, 0, math.deg(math.atan(vz, 3.0)) * -1, 0)
-- The platform slides in Z.
nodeSetPosition(platform, 8.5, 2.1, 1.5 + math.sin(t * 1.5) * 1.5)
-- Camera behind and above the player.
nodeSetPosition(camera, px - 4.5, py + 2.6, pz + 4.5)
nodeLookAt(camera, px + 1, py + 0.6, pz)
overlayClear()
fontPrint(20, 20, string.format("Player, frame %d x %.1f y %.2f ground %s landings %d", frames, px, py, tostring(playerIsOnGround(player)), landings))
if frames % 30 == 0 then
local fx, fy, fz = nodeGetWorldPosition(fox)
local vx, vy, vz = playerGetVelocity(player)
local cx, cy, cz = nodeGetPosition(crate)
debugPrint(string.format("frame %d player %.2f %.2f %.2f v %.2f %.2f %.2f ground %s jumped %s crate %.2f %.2f landings %d", frames, px, py, pz, vx, vy, vz, tostring(playerIsOnGround(player)), tostring(jumped), cx, cz, landings))
end
if frames == 40 or frames == 80 or frames == 120 or frames == 160 then
singeScreenshot()
end
if frames == 180 then
singeQuit()
end
end

69
testScripts/scene20.singe Normal file
View file

@ -0,0 +1,69 @@
-- Character controller in 2D: a platformer drawn on the overlay with sprites and boxes, no GPU
-- needed. The player runs right, hops a gap, climbs a step, is stopped by a wall and jumps onto a
-- ledge, on a scripted route; gravity points down the screen (+Y) like every 2D game here.
local font = fontLoad("Singe/FreeSansBold.ttf", 24)
local crate = spriteLoad("testScripts/crate.png")
local frames = 0
overlaySetResolution(720, 480)
local width, height = overlayGetWidth(), overlayGetHeight()
fontSelect(font)
physicsSet2D(true)
physicsSetGravity(0, 900, 0)
local blocks = {}
local function block(x, y, w, h)
local node = nodeNew()
nodeSetPosition(node, x, y, 0)
bodyNew(node, BODY_STATIC, SHAPE_BOX, w, h, 50)
blocks[#blocks + 1] = { x = x, y = y, w = w, h = h }
return node
end
-- Ground with a gap, a low step, a wall, and a ledge to jump onto.
local ground = height - 20
block(width * 0.2, ground, width * 0.4, 20)
block(width * 0.72, ground, width * 0.56, 20)
block(width * 0.55, ground - 22, 60, 24)
block(width * 0.85, ground - 60, 20, 100)
block(width * 0.92, ground - 140, 140, 20)
local size = spriteGetWidth(crate)
local player = nodeNew()
nodeSetPosition(player, 40, ground - 10, 0)
playerNew(player, size * 0.45, size * 1.4)
playerSetStep(player, 26)
local hops = 0
function onOverlayUpdate()
local px, py = nodeGetPosition(player)
frames = frames + 1
playerMove(player, (px < width * 0.9) and 220 or 0)
-- Hop the gap, and later the wall, when the ground runs out ahead or the wall is near.
if playerIsOnGround(player) and ((px > width * 0.36 and px < width * 0.42) or (px > width * 0.70 and px < width * 0.75)) then
if playerJump(player, 560) then
hops = hops + 1
end
end
overlayClear()
colorForeground(90, 90, 110)
for _, b in ipairs(blocks) do
overlayBox(b.x - b.w / 2, b.y - b.h / 2, b.x + b.w / 2, b.y + b.h / 2)
end
spriteDraw(crate, px - size / 2, py - size * 1.4, px + size / 2, py)
colorForeground(255, 255, 255)
fontPrint(20, 20, string.format("Platformer, frame %d x %.0f y %.0f ground %s hops %d", frames, px, py, tostring(playerIsOnGround(player)), hops))
if frames % 10 == 0 then
local vx, vy = playerGetVelocity(player)
debugPrint(string.format("frame %d player %.0f %.0f v %.0f %.0f ground %s hops %d", frames, px, py, vx, vy, tostring(playerIsOnGround(player)), hops))
end
if frames == 40 or frames == 90 or frames == 140 then
singeScreenshot()
end
if frames == 180 then
singeQuit()
end
return OVERLAY_UPDATED
end

131
testScripts/scene21.singe Normal file
View file

@ -0,0 +1,131 @@
-- Point-light shadows inside geometry: two bulbs in a closed room built from ordinary boxes (their
-- faces point outward, so from inside only back faces show), with pillars. One bulb hangs under a
-- cone shade on a stand; the other sits inside a cage of bars and rings. Inside, the walls are lit
-- with no seams, the shade throws its shadow on the ceiling and the cage its stripes across the
-- corners; outside, the room stays dark.
local font = fontLoad("Singe/FreeSansBold.ttf", 28)
local frames = 0
fontSelect(font)
discPlay()
sceneEnable(true)
sceneSetBackground(0, 0, 0, 0)
sceneSetAmbient(8, 8, 12)
local plaster = materialNew()
materialSetColor(plaster, 200, 190, 170)
materialSetRoughness(plaster, 0.95)
materialSetDoubleSided(plaster, true)
local brass = materialNew()
materialSetColor(brass, 180, 140, 70)
materialSetMetallic(brass, 0.7)
materialSetRoughness(brass, 0.4)
materialSetDoubleSided(brass, true)
local glow = materialNew()
materialSetColor(glow, 255, 240, 200)
materialSetEmissive(glow, 255, 230, 170)
materialSetUnlit(glow, true)
-- The room: six slabs 8 x 4 x 8 inside, all outward facing like any box.
local function slab(x, y, z, w, h, d)
local node = nodeNew()
nodeSetMesh(node, meshBox(w, h, d), plaster)
nodeSetPosition(node, x, y, z)
return node
end
slab(0, -0.1, 0, 8.4, 0.2, 8.4)
slab(0, 4.1, 0, 8.4, 0.2, 8.4)
slab(-4.1, 2, 0, 0.2, 4.4, 8.4)
slab(4.1, 2, 0, 0.2, 4.4, 8.4)
slab(0, 2, -4.1, 8.4, 4.4, 0.2)
slab(0, 2, 4.1, 8.4, 4.4, 0.2)
-- Pillars and a crate.
for i, p in ipairs({ { -2.5, -2.5 }, { 2.5, -2.5 }, { -2.5, 2.5 }, { 2.5, 2.5 } }) do
local pillar = nodeNew()
nodeSetMesh(pillar, meshCylinder(0.25, 4, 16), plaster)
nodeSetPosition(pillar, p[1], 2, p[2])
end
local crate = nodeNew()
nodeSetMesh(crate, meshBox(0.8, 0.8, 0.8), brass)
nodeSetPosition(crate, 2.2, 0.4, -1.8)
-- The lamp: a stand, a cone shade above the bulb, which hangs just below its rim so the shade
-- throws a ring of shadow on the ceiling and the bulb lights everything below.
local stand = nodeNew()
nodeSetMesh(stand, meshCylinder(0.04, 1.4, 8), brass)
nodeSetPosition(stand, -0.5, 0.7, -0.3)
local shade = nodeNew()
nodeSetMesh(shade, meshCone(0.45, 0.5, 24), brass)
nodeSetPosition(shade, -0.5, 2.05, -0.3)
local bulbMesh = nodeNew()
nodeSetMesh(bulbMesh, meshSphere(0.08, 12), glow)
nodeSetPosition(bulbMesh, -0.5, 1.55, -0.3)
nodeSetShadow(bulbMesh, false) -- The bulb's own glass casts nothing.
local bulb = lightNew(LIGHT_POINT)
nodeSetPosition(bulb, -0.5, 1.55, -0.3)
lightSetIntensity(bulb, 6)
lightSetShadow(bulb, true)
-- The second lamp: a bulb inside a cage of eight bars and three rings of short segments, its own
-- glass excused from casting. The bars are the harsh case: thin casters right beside the light.
local cx, cy, cz = 2.2, 2.1, 1.6
local function bar(x, y, z, rx, ry, rz, radius, h)
local node = nodeNew()
nodeSetMesh(node, meshCylinder(radius, h, 8), brass)
nodeSetPosition(node, x, y, z)
nodeSetRotation(node, rx, ry, rz)
return node
end
for i = 0, 7 do
local a = i / 8 * 2 * math.pi
bar(cx + 0.35 * math.cos(a), cy, cz + 0.35 * math.sin(a), 0, 0, 0, 0.02, 1.0)
end
for _, dy in ipairs({ -0.5, 0, 0.5 }) do
for i = 0, 7 do
local a = (i + 0.5) / 8 * 2 * math.pi
bar(cx + 0.35 * math.cos(a), cy + dy, cz + 0.35 * math.sin(a), 0, -math.deg(a), 90, 0.015, 0.28)
end
end
local cagedMesh = nodeNew()
nodeSetMesh(cagedMesh, meshSphere(0.06, 12), glow)
nodeSetPosition(cagedMesh, cx, cy, cz)
nodeSetShadow(cagedMesh, false)
local caged = lightNew(LIGHT_POINT)
nodeSetPosition(caged, cx, cy, cz)
lightSetIntensity(caged, 4)
lightSetColor(caged, 255, 230, 190)
lightSetShadow(caged, true)
local camera = nodeNew()
cameraSet(camera)
cameraSetPerspective(70, 0.05, 60)
function onOverlayUpdate()
frames = frames + 1
overlayClear()
if frames < 50 then
-- Inside: from a corner, looking across the room at the shaded lamp, pillars and the far wall.
nodeSetPosition(camera, 3.2 - frames * 0.006, 1.6, -3.2)
nodeLookAt(camera, -1.5, 1.4, 1.5)
elseif frames < 95 then
-- Inside, looking up at the ceiling above the shade.
nodeSetPosition(camera, 1.5, 0.8, -1.5)
nodeLookAt(camera, -0.5, 3.8, -0.3)
elseif frames < 140 then
-- Under the cage, looking up at its stripes running over the ceiling corner.
nodeSetPosition(camera, 1.6, 0.5, 1.0)
nodeLookAt(camera, 3.6, 3.8, 3.6)
else
-- Outside: the closed room from above; nothing of either bulb may leak out.
nodeSetPosition(camera, 9, 7, 9)
nodeLookAt(camera, 0, 1.5, 0)
end
fontPrint(20, 20, "Bulbs in a room, frame " .. frames)
if frames == 40 or frames == 85 or frames == 130 or frames == 175 then
singeScreenshot()
end
if frames == 180 then
singeQuit()
end
end

110
testScripts/scene22.singe Normal file
View file

@ -0,0 +1,110 @@
-- Vehicles: a car built from primitives (a box chassis with a dynamic body, four wheel nodes) on a
-- track with a ramp, crates and a wall, driven by a scripted route: pull away, jump the ramp, brake,
-- turn, and reverse into the crates. The camera chases.
local font = fontLoad("Singe/FreeSansBold.ttf", 28)
local frames = 0
local turned = false
local reversed = false
fontSelect(font)
discPlay()
sceneEnable(true)
sceneSetBackground(0, 0, 0, 0)
sceneSetAmbient(70, 70, 80)
local asphalt = materialNew()
materialSetColor(asphalt, 70, 70, 75)
materialSetRoughness(asphalt, 0.95)
local paint = materialNew()
materialSetColor(paint, 200, 40, 40)
materialSetMetallic(paint, 0.5)
materialSetRoughness(paint, 0.3)
local rubber = materialNew()
materialSetColor(rubber, 30, 30, 30)
local wood = materialNew()
materialSetColor(wood, 150, 100, 50)
local function block(x, y, z, w, h, d, material, rz)
local node = nodeNew()
nodeSetMesh(node, meshBox(w, h, d), material)
nodeSetPosition(node, x, y, z)
if rz then nodeSetRotation(node, rz, 0, 0) end
bodyNew(node, BODY_STATIC, SHAPE_BOX, w, h, d)
return node
end
-- The track runs along -Z (the car's nose). A long floor, a ramp, a landing, crates and a wall.
block(0, -0.1, -60, 40, 0.2, 200, asphalt)
block(0, 0.45, -18, 6, 0.2, 5, asphalt, 12)
block(0, 0.9, -23.4, 6, 0.2, 6, asphalt)
for i = 0, 5 do
local crate = nodeNew()
nodeSetMesh(crate, meshBox(0.6, 0.6, 0.6), wood)
nodeSetPosition(crate, -3 + (i % 3) * 0.65, 0.3 + math.floor(i / 3) * 0.62, -36)
bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.6, 0.6, 0.6)
bodySetMass(crate, 4)
end
block(8, 1, -30, 0.3, 2, 12, asphalt)
-- The car: chassis 1.8 x 0.5 x 4.0, wheels as children whose X axis is the axle.
local car = nodeNew()
nodeSetMesh(car, meshBox(1.8, 0.5, 4.0), paint)
nodeSetPosition(car, 0, 0.9, 0)
bodyNew(car, BODY_DYNAMIC, SHAPE_BOX, 1.8, 0.5, 4.0)
bodySetMass(car, 1500)
vehicleNew(car, VEHICLE_CAR)
local wheels = {}
for i, p in ipairs({ { -0.95, -0.2, -1.4 }, { 0.95, -0.2, -1.4 }, { -0.95, -0.2, 1.4 }, { 0.95, -0.2, 1.4 } }) do
local wheel = nodeNew()
nodeSetParent(wheel, car)
nodeSetPosition(wheel, p[1], p[2], p[3])
local tyre = nodeNew()
nodeSetParent(tyre, wheel)
nodeSetMesh(tyre, meshCylinder(0.35, 0.25, 16), rubber)
nodeSetRotation(tyre, 0, 0, 90)
wheels[i] = vehicleAddWheel(car, wheel, 0.35, 0.25, 0.4)
end
vehicleSetEngine(car, 600, 6500, 1000)
vehicleSetAntiRoll(car, 800)
local sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(sun, -5, 12, 6)
nodeLookAt(sun, 0, 0, -15)
lightSetIntensity(sun, 1.5)
lightSetShadow(sun, true)
local camera = nodeNew()
cameraSet(camera)
cameraSetPerspective(60, 0.1, 200)
function onOverlayUpdate()
local cx, cy, cz = nodeGetPosition(car)
local t = frames / 60
frames = frames + 1
-- Route by distance, since physics runs on the clock: floor it over the ramp, then brake and
-- steer, then reverse toward the crates, then stop.
if cz > -30 and not turned then
vehicleDrive(car, 1, 0, 0, 0)
elseif not turned then
vehicleDrive(car, 0, 0.7, 0.6, 0)
if vehicleGetSpeed(car) < 2 then turned = true end
elseif not reversed then
vehicleDrive(car, -1, 0, 0, 0)
if vehicleGetSpeed(car) < -6 then reversed = true end
else
vehicleDrive(car, 0, 0, 1, 1)
end
nodeSetPosition(camera, cx + 3, cy + 3, cz + 9)
nodeLookAt(camera, cx, cy, cz - 3)
overlayClear()
fontPrint(20, 20, string.format("Car, frame %d speed %.1f m/s gear %d rpm %.0f", frames, vehicleGetSpeed(car), vehicleGetGear(car), vehicleGetRpm(car)))
if frames % 30 == 0 then
debugPrint(string.format("frame %d car %.1f %.2f %.1f speed %.1f gear %d rpm %.0f ground %s %s %s %s slip %.2f", frames, cx, cy, cz, vehicleGetSpeed(car), vehicleGetGear(car), vehicleGetRpm(car), tostring(vehicleIsWheelOnGround(car, 0)), tostring(vehicleIsWheelOnGround(car, 1)), tostring(vehicleIsWheelOnGround(car, 2)), tostring(vehicleIsWheelOnGround(car, 3)), vehicleGetWheelSlip(car, 2)))
end
if frames == 60 or frames == 120 or frames == 170 then
singeScreenshot()
end
if frames == 180 then
singeQuit()
end
end

118
testScripts/scene23.singe Normal file
View file

@ -0,0 +1,118 @@
-- Vehicles, second kind: a tank on six wheels that drives, pivots on the spot and climbs a slope,
-- and a motorcycle that leans through a turn, both from primitives, side by side.
local font = fontLoad("Singe/FreeSansBold.ttf", 28)
local frames = 0
fontSelect(font)
discPlay()
sceneEnable(true)
sceneSetBackground(0, 0, 0, 0)
sceneSetAmbient(70, 70, 80)
local ground = materialNew()
materialSetColor(ground, 90, 110, 70)
materialSetRoughness(ground, 0.95)
local armour = materialNew()
materialSetColor(armour, 80, 90, 70)
local rubber = materialNew()
materialSetColor(rubber, 30, 30, 30)
local chrome = materialNew()
materialSetColor(chrome, 200, 200, 210)
materialSetMetallic(chrome, 0.8)
materialSetRoughness(chrome, 0.2)
local floor = nodeNew()
nodeSetMesh(floor, meshBox(160, 0.2, 160), ground)
nodeSetPosition(floor, 0, -0.1, -30)
bodyNew(floor, BODY_STATIC, SHAPE_BOX, 160, 0.2, 160)
local slope = nodeNew()
nodeSetMesh(slope, meshBox(8, 0.2, 8), ground)
nodeSetPosition(slope, -6, 0.7, -24)
nodeSetRotation(slope, 20, 0, 0)
bodyNew(slope, BODY_STATIC, SHAPE_BOX, 8, 0.2, 8)
local function wheel(parent, x, y, z, radius, width)
local node = nodeNew()
nodeSetParent(node, parent)
nodeSetPosition(node, x, y, z)
local tyre = nodeNew()
nodeSetParent(tyre, node)
nodeSetMesh(tyre, meshCylinder(radius, width, 12), rubber)
nodeSetRotation(tyre, 0, 0, 90)
return node
end
-- The tank: a heavy hull, three road wheels a side.
local tank = nodeNew()
nodeSetMesh(tank, meshBox(3, 1, 5), armour)
nodeSetPosition(tank, -6, 1.2, 0)
bodyNew(tank, BODY_DYNAMIC, SHAPE_BOX, 3, 1, 5)
bodySetMass(tank, 8000)
vehicleNew(tank, VEHICLE_TANK)
for _, z in ipairs({ -1.8, 0, 1.8 }) do
vehicleAddWheel(tank, wheel(tank, -1.7, -0.4, z, 0.5, 0.4), 0.5, 0.4, 0.5)
vehicleAddWheel(tank, wheel(tank, 1.7, -0.4, z, 0.5, 0.4), 0.5, 0.4, 0.5)
end
vehicleSetEngine(tank, 1500, 4000, 800)
-- The motorcycle: a slim frame, one wheel each end, the rear driven.
local bike = nodeNew()
nodeSetMesh(bike, meshBox(0.3, 0.6, 1.6), chrome)
nodeSetPosition(bike, 4, 0.9, 0)
bodyNew(bike, BODY_DYNAMIC, SHAPE_BOX, 0.3, 0.6, 1.6)
bodySetMass(bike, 250)
vehicleNew(bike, VEHICLE_MOTORCYCLE)
vehicleAddWheel(bike, wheel(bike, 0, -0.3, -0.8, 0.35, 0.12), 0.35, 0.12, 0.3)
vehicleAddWheel(bike, wheel(bike, 0, -0.3, 0.8, 0.35, 0.12), 0.35, 0.12, 0.3)
vehicleSetEngine(bike, 150, 9000, 1200)
local sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(sun, -5, 12, 6)
nodeLookAt(sun, 0, 0, -15)
lightSetIntensity(sun, 1.5)
lightSetShadow(sun, true)
local camera = nodeNew()
cameraSet(camera)
cameraSetPerspective(60, 0.1, 200)
function onOverlayUpdate()
local tx, ty, tz = nodeGetPosition(tank)
local bx, by, bz = nodeGetPosition(bike)
local t = frames / 60
frames = frames + 1
-- Tank: forward, then pivot, then on toward the slope. Bike: accelerate, then lean into a turn.
if tz > -12 and frames < 100 then
vehicleDrive(tank, 1, 0, 0, 0)
elseif frames < 130 then
vehicleDrive(tank, 0, 1, 0, 0)
else
vehicleDrive(tank, 1, 0, 0, 0)
end
if bz > -15 then
vehicleDrive(bike, 1, 0, 0, 0)
else
vehicleDrive(bike, 0.4, 0.35, 0, 0)
end
-- The camera watches the tank for the first two thirds, then chases the bike.
if frames < 125 then
nodeSetPosition(camera, tx + 8, ty + 5, tz + 10)
nodeLookAt(camera, tx, ty, tz)
else
nodeSetPosition(camera, bx + 6, by + 3, bz + 8)
nodeLookAt(camera, bx, by, bz)
end
overlayClear()
fontPrint(20, 20, string.format("Tank %.1f m/s bike %.1f m/s frame %d", vehicleGetSpeed(tank), vehicleGetSpeed(bike), frames))
if frames % 30 == 0 then
local _, ry = nodeGetRotation(tank)
local _, _, roll = nodeGetRotation(bike)
debugPrint(string.format("frame %d tank %.1f %.2f %.1f yaw %.0f speed %.1f | bike %.1f %.2f %.1f roll %.0f speed %.1f", frames, tx, ty, tz, ry, vehicleGetSpeed(tank), bx, by, bz, roll, vehicleGetSpeed(bike)))
end
if frames == 60 or frames == 120 or frames == 170 then
singeScreenshot()
end
if frames == 180 then
singeQuit()
end
end

137
testScripts/scene24.singe Normal file
View file

@ -0,0 +1,137 @@
-- Water: a pool that floats a light crate, sinks an anchor and bobs a ball; a river with a current
-- that carries a crate; the Fox wading in and swimming across; a raft with a propeller and rudder.
local font = fontLoad("Singe/FreeSansBold.ttf", 28)
local frames = 0
fontSelect(font)
discPlay()
sceneEnable(true)
sceneSetBackground(0, 0, 0, 0)
sceneSetAmbient(70, 70, 80)
local stone = materialNew()
materialSetColor(stone, 130, 130, 140)
materialSetRoughness(stone, 0.9)
local wood = materialNew()
materialSetColor(wood, 160, 110, 60)
local iron = materialNew()
materialSetColor(iron, 60, 60, 70)
materialSetMetallic(iron, 0.8)
local water = materialNew()
materialSetColor(water, 60, 120, 200)
materialSetBlend(water, true)
materialSetRoughness(water, 0.2)
local red = materialNew()
materialSetColor(red, 220, 50, 50)
-- The ground, a pool cut into it (a deck round the water), and a river channel with a current.
local ground = nodeNew()
nodeSetMesh(ground, meshBox(30, 0.2, 30), stone)
nodeSetPosition(ground, 0, -3.1, 0)
bodyNew(ground, BODY_STATIC, SHAPE_BOX, 30, 0.2, 30)
local deck = nodeNew()
nodeSetMesh(deck, meshBox(4, 3, 12), stone)
nodeSetPosition(deck, -6, -1.5, 0)
bodyNew(deck, BODY_STATIC, SHAPE_BOX, 4, 3, 12)
local pool = nodeNew()
nodeSetMesh(pool, meshBox(8, 3, 12), water)
nodeSetPosition(pool, 0, -1.5, 0)
bodyNew(pool, BODY_STATIC, SHAPE_BOX, 8, 3, 12)
bodySetWater(pool, 1.0, 0.6, 0.2)
local river = nodeNew()
nodeSetMesh(river, meshBox(6, 3, 12), water)
nodeSetPosition(river, 7, -1.5, 0)
bodyNew(river, BODY_STATIC, SHAPE_BOX, 6, 3, 12)
bodySetWater(river, 1.0, 0.8, 0.2)
bodySetCurrent(river, 0, 0, 2.5)
-- Things in the water: a crate that floats, an anchor that sinks, a ball, a crate in the river.
local crate = nodeNew()
nodeSetMesh(crate, meshBox(0.8, 0.8, 0.8), wood)
nodeSetPosition(crate, -1, 1.5, 2.5)
bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.8, 0.8, 0.8)
bodySetMass(crate, 20)
bodySetBuoyancy(crate, 1.6)
local anchor = nodeNew()
nodeSetMesh(anchor, meshBox(0.5, 0.5, 0.5), iron)
nodeSetPosition(anchor, 1.5, 1.5, -3)
bodyNew(anchor, BODY_DYNAMIC, SHAPE_BOX, 0.5, 0.5, 0.5)
bodySetMass(anchor, 200)
bodySetBuoyancy(anchor, 0.3)
local ball = nodeNew()
nodeSetMesh(ball, meshSphere(0.4, 16), red)
nodeSetPosition(ball, 2, 2.5, 2)
bodyNew(ball, BODY_DYNAMIC, SHAPE_SPHERE, 0.4)
bodySetMass(ball, 2)
bodySetBuoyancy(ball, 2.0)
local drift = nodeNew()
nodeSetMesh(drift, meshBox(0.7, 0.7, 0.7), wood)
nodeSetPosition(drift, 7, 0.5, -5)
bodyNew(drift, BODY_DYNAMIC, SHAPE_BOX, 0.7, 0.7, 0.7)
bodySetMass(drift, 10)
bodySetBuoyancy(drift, 1.5)
-- The raft: a flat hull with the propeller at the stern.
local raft = nodeNew()
nodeSetMesh(raft, meshBox(1.6, 0.3, 2.4), wood)
nodeSetPosition(raft, 1, 0.3, 4)
bodyNew(raft, BODY_DYNAMIC, SHAPE_BOX, 1.6, 0.3, 2.4)
bodySetMass(raft, 120)
bodySetBuoyancy(raft, 3.0)
vehicleNew(raft, VEHICLE_BOAT)
vehicleSetThrust(raft, 600, 0, -0.1, 1.2)
vehicleSetRudder(raft, 300)
-- The Fox wades in from the deck and swims across.
local hero = nodeNew()
nodeSetPosition(hero, -5, 0.05, -2)
playerNew(hero, 0.3, 1.0)
local fox = modelInstance(modelLoad("testScripts/Models/Fox.glb"))
nodeSetParent(fox, hero)
nodeSetScale(fox, 0.01)
nodeSetRotation(fox, 0, 90, 0)
animationPlay(fox, "Walk", true)
local sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(sun, -5, 10, 6)
nodeLookAt(sun, 0, 0, 0)
lightSetIntensity(sun, 1.5)
lightSetShadow(sun, true)
local camera = nodeNew()
nodeSetPosition(camera, 1, 6, 12)
nodeLookAt(camera, 1, -0.5, 0)
cameraSet(camera)
function onTrigger(trigger, other, entered)
if other == hero then
debugPrint(string.format("frame %d fox %s %s", frames, entered and "entered" or "left", (trigger == pool) and "the pool" or ((trigger == river) and "the river" or "a trigger")))
end
end
function onOverlayUpdate()
local hx, hy, hz = nodeGetPosition(hero)
frames = frames + 1
if playerIsSwimming(hero) then
playerMove(hero, 1.5, 0.6, 0)
else
playerMove(hero, 1.5, 0)
end
vehicleDrive(raft, 0.7, (frames > 30) and 0.9 or 0, 0)
overlayClear()
fontPrint(20, 20, string.format("Water, frame %d fox %s at %.1f %.2f raft %.1f m/s", frames, playerIsSwimming(hero) and "swimming" or "walking", hx, hy, vehicleGetSpeed(raft)))
if frames % 30 == 0 then
local cx, cy = nodeGetPosition(crate)
local ax, ay = nodeGetPosition(anchor)
local bx, by = nodeGetPosition(ball)
local dx, dy, dz = nodeGetPosition(drift)
local rx, ry, rz = nodeGetPosition(raft)
debugPrint(string.format("frame %d fox %.1f %.2f %s | crate y %.2f anchor y %.2f ball y %.2f | drift z %.1f | raft %.1f %.2f %.1f speed %.1f", frames, hx, hy, tostring(playerIsSwimming(hero)), cy, ay, by, dz, rx, ry, rz, vehicleGetSpeed(raft)))
end
if frames == 50 or frames == 110 or frames == 170 then
singeScreenshot()
end
if frames == 180 then
singeQuit()
end
end

105
testScripts/scene25.singe Normal file
View file

@ -0,0 +1,105 @@
-- Ragdolls: one Fox runs at a wall and collapses on impact; another is dropped onto a flight of
-- stairs limp; a third lies on the floor and gets up again under motor strength.
local font = fontLoad("Singe/FreeSansBold.ttf", 28)
local frames = 0
fontSelect(font)
discPlay()
sceneEnable(true)
sceneSetBackground(0, 0, 0, 0)
sceneSetAmbient(70, 70, 80)
local stone = materialNew()
materialSetColor(stone, 120, 120, 130)
materialSetRoughness(stone, 0.9)
local steel = materialNew()
materialSetColor(steel, 90, 110, 150)
local function block(x, y, z, w, h, d, material)
local node = nodeNew()
nodeSetMesh(node, meshBox(w, h, d), material)
nodeSetPosition(node, x, y, z)
bodyNew(node, BODY_STATIC, SHAPE_BOX, w, h, d)
return node
end
block(0, -0.1, 0, 20, 0.2, 14, stone)
block(4, 0.75, -1, 0.3, 1.5, 3, steel) -- The wall the runner hits
for i = 1, 5 do
block(-4 - i * 0.5, i * 0.15, 3, 0.5, i * 0.3, 3, stone) -- Stairs going up to the left
end
local foxModel = modelLoad("testScripts/Models/Fox.glb")
-- The runner: a player carrying the Fox; the ragdoll is made now and switched on at the crash.
local runner = nodeNew()
nodeSetPosition(runner, -1, 0.05, -1)
playerNew(runner, 0.3, 0.9)
local runnerFox = modelInstance(foxModel)
nodeSetParent(runnerFox, runner)
nodeSetScale(runnerFox, 0.01)
nodeSetRotation(runnerFox, 0, 90, 0)
animationPlay(runnerFox, "Run", true)
ragdollNew(runnerFox)
-- The faller: dropped limp from above the stairs.
local faller = modelInstance(foxModel)
nodeSetPosition(faller, -6.5, 2.5, 3)
nodeSetScale(faller, 0.01)
nodeSetRotation(faller, 30, 40, 0)
animationPlay(faller, "Survey", true)
ragdollNew(faller)
ragdollActivate(faller)
-- The riser: lying limp on the floor, then pulled back toward its standing pose.
local riser = modelInstance(foxModel)
nodeSetPosition(riser, 3, 0, 3.5)
nodeSetScale(riser, 0.01)
nodeSetRotation(riser, 0, -30, 0)
animationPlay(riser, "Survey", true)
ragdollNew(riser)
ragdollActivate(riser)
local sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(sun, -4, 8, 6)
nodeLookAt(sun, 0, 0, 0)
lightSetIntensity(sun, 1.5)
lightSetShadow(sun, true)
local camera = nodeNew()
nodeSetPosition(camera, 0, 4, 9)
nodeLookAt(camera, -1, 0.5, 0)
cameraSet(camera)
local crashed = false
function onCollision(a, b, x, y, z, speed)
if not crashed and (a == runner or b == runner) and speed > 1.5 then
crashed = true
playerSetEnabled(runner, false)
ragdollActivate(runnerFox)
ragdollApplyImpulse(runnerFox, "b_Spine02_03", 40, 30, 0)
end
end
function onOverlayUpdate()
frames = frames + 1
if not crashed then
playerMove(runner, 4, 0)
end
if frames == 100 then
ragdollSetStrength(riser, 60)
end
overlayClear()
fontPrint(20, 20, string.format("Ragdolls, frame %d %s faller %s riser %s", frames, crashed and "crashed" or "running", ragdollIsResting(faller) and "resting" or "moving", ragdollIsResting(riser) and "resting" or "moving"))
if frames % 30 == 0 then
local rx, ry, rz = nodeGetPosition(runner)
local fx, fy, fz = nodeGetWorldPosition(faller)
debugPrint(string.format("frame %d runner %.2f %.2f crashed %s | faller %.2f %.2f %.2f resting %s | riser resting %s", frames, rx, ry, tostring(crashed), fx, fy, fz, tostring(ragdollIsResting(faller)), tostring(ragdollIsResting(riser))))
end
if frames == 50 or frames == 110 or frames == 170 then
singeScreenshot()
end
if frames == 180 then
singeQuit()
end
end

112
testScripts/scene26.singe Normal file
View file

@ -0,0 +1,112 @@
-- Soft bodies: a flag pinned to a pole, a sheet pinned at four corners catching a crate, a rope
-- swinging from a crane, and a balloon under pressure dropped onto the floor.
local font = fontLoad("Singe/FreeSansBold.ttf", 28)
local frames = 0
fontSelect(font)
discPlay()
sceneEnable(true)
sceneSetBackground(0, 0, 0, 0)
sceneSetAmbient(70, 70, 80)
local stone = materialNew()
materialSetColor(stone, 120, 120, 130)
materialSetRoughness(stone, 0.9)
local cloth = materialNew()
materialSetColor(cloth, 220, 60, 60)
materialSetDoubleSided(cloth, true)
materialSetRoughness(cloth, 0.8)
local linen = materialNew()
materialSetColor(linen, 230, 225, 200)
materialSetDoubleSided(linen, true)
local wood = materialNew()
materialSetColor(wood, 150, 100, 50)
local hemp = materialNew()
materialSetColor(hemp, 190, 160, 100)
local rubber = materialNew()
materialSetColor(rubber, 80, 180, 90)
local floor = nodeNew()
nodeSetMesh(floor, meshBox(16, 0.2, 12), stone)
nodeSetPosition(floor, 0, -0.1, 0)
bodyNew(floor, BODY_STATIC, SHAPE_BOX, 16, 0.2, 12)
-- The flag: a grid standing upright, pinned along its left edge to the pole.
local pole = nodeNew()
nodeSetMesh(pole, meshCylinder(0.04, 3, 8), wood)
nodeSetPosition(pole, -5, 1.5, 0)
bodyNew(pole, BODY_STATIC, SHAPE_CYLINDER, 0.04, 3)
local flag = nodeNew()
nodeSetMesh(flag, meshPlane(1.6, 1.0, 16, 10), cloth)
nodeSetPosition(flag, -4.16, 2.4, 0)
nodeSetRotation(flag, 90, 0, 0)
softNew(flag, SOFT_CLOTH)
softSetStiffness(flag, 0.95, 0.1)
softSetMass(flag, 0.5)
for _, y in ipairs({ 1.9, 2.15, 2.4, 2.65, 2.9 }) do
softPin(flag, -4.96, y, 0)
end
-- The sheet: pinned at four corners, a crate dropped onto it.
local sheet = nodeNew()
nodeSetMesh(sheet, meshPlane(3, 3, 20, 20), linen)
nodeSetPosition(sheet, 0, 1.6, 0)
softNew(sheet, SOFT_CLOTH)
softSetStiffness(sheet, 1.0, 0.05)
softSetMass(sheet, 4)
for _, c in ipairs({ { -1.5, -1.5 }, { 1.5, -1.5 }, { -1.5, 1.5 }, { 1.5, 1.5 } }) do
softPin(sheet, c[1], 1.6, c[2])
end
local crate = nodeNew()
nodeSetMesh(crate, meshBox(0.6, 0.6, 0.6), wood)
nodeSetPosition(crate, 0.2, 3.5, 0.1)
nodeSetRotation(crate, 20, 30, 0)
bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.6, 0.6, 0.6)
bodySetMass(crate, 3)
-- The rope: from a crane arm out and down, swinging free from a horizontal start.
local crane = nodeNew()
nodeSetPosition(crane, 4, 4, -1)
local rope = nodeNew()
nodeSetPosition(rope, 4, 4, -1)
softNew(rope, SOFT_ROPE, 6.5, 4, -1, 16, 0.04)
nodeSetMaterial(rope, hemp)
softSetMass(rope, 1.5)
softPin(rope, 4, 4, -1, crane)
-- The balloon: a sphere kept round by pressure, dropped and squashed.
local balloon = nodeNew()
nodeSetMesh(balloon, meshSphere(0.5, 14), rubber)
nodeSetPosition(balloon, 4, 2.5, 2)
softNew(balloon, SOFT_BODY)
softSetStiffness(balloon, 0.8, 0.3)
softSetMass(balloon, 0.6)
softSetPressure(balloon, 8)
local sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(sun, -4, 8, 6)
nodeLookAt(sun, 0, 0, 0)
lightSetIntensity(sun, 1.5)
lightSetShadow(sun, true)
local camera = nodeNew()
nodeSetPosition(camera, 0, 3.5, 9)
nodeLookAt(camera, 0, 1.5, 0)
cameraSet(camera)
function onOverlayUpdate()
frames = frames + 1
overlayClear()
fontPrint(20, 20, "Soft bodies, frame " .. frames)
if frames % 30 == 0 then
local cx, cy = nodeGetPosition(crate)
local bx, by = nodeGetPosition(balloon)
debugPrint(string.format("frame %d crate y %.2f balloon y %.2f", frames, cy, by))
end
if frames == 40 or frames == 100 or frames == 170 then
singeScreenshot()
end
if frames == 180 then
singeQuit()
end
end

105
testScripts/scene27.singe Normal file
View file

@ -0,0 +1,105 @@
-- The Khronos Sponza atrium, packed with util/packGlb.py from the glTF-Sample-Models 2.0 Sponza.
-- A warm sun falls through the roof opening onto the atrium floor with hard shadows, braziers along the nave
-- add pools of firelight with particle flames and smoke, and dust drifts in the sunbeams; the
-- camera walks the ground floor, crosses the atrium, climbs to the gallery, then passes the columns.
local font = fontLoad("Singe/FreeSansBold.ttf", 28)
local frames = 0
fontSelect(font)
sceneEnable(true)
sceneSetBackground(120, 150, 200, 255)
sceneSetAmbient(52, 56, 74)
sceneSetShadowSize(2048)
local model = modelLoad("testScripts/Models/Sponza.glb")
local sponza = modelInstance(model)
-- The sun: high and warm so it falls through the roof opening onto the atrium floor.
local sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(sun, 5, 22, 4)
nodeLookAt(sun, -2, 0, -1.5)
lightSetIntensity(sun, 4.5)
lightSetColor(sun, 255, 236, 205)
lightSetShadow(sun, true)
-- Braziers: warm point lights along the nave, the two nearest the camera path casting shadows,
-- each with a flame and a thread of smoke.
local fire = materialNew()
materialSetColor(fire, 60, 40, 30)
local braziers = {}
for i, p in ipairs({ { 6.5, 1.1, -2.2 }, { 6.5, 1.1, 2.2 }, { -6.5, 1.1, -2.2 }, { -6.5, 1.1, 2.2 }, { 0, 1.1, -2.4 }, { 0, 1.1, 2.4 } }) do
local bowl = nodeNew()
nodeSetMesh(bowl, meshCylinder(0.35, 0.25, 12), fire)
nodeSetPosition(bowl, p[1], p[2] - 0.15, p[3])
local glowNode = nodeNew()
nodeSetPosition(glowNode, p[1], p[2] + 0.35, p[3])
local lamp = lightNew(LIGHT_POINT)
nodeSetParent(lamp, glowNode)
lightSetColor(lamp, 255, 170, 80)
lightSetIntensity(lamp, 3.4)
lightSetRange(lamp, 12)
lightSetShadow(lamp, i <= 2)
local flame = emitterNew(glowNode)
emitterSetBlend(flame, PARTICLE_ADD)
emitterSetRate(flame, 60)
emitterSetLife(flame, 0.4, 0.8)
emitterSetSpeed(flame, 0.5, 1.1)
emitterSetDirection(flame, 0, 1, 0)
emitterSetSpread(flame, 18)
emitterSetSize(flame, 0.22, 0.04, 0.15)
emitterSetColor(flame, 255, 190, 90, 255, 255, 60, 0, 0)
emitterSetRadius(flame, 0.1)
emitterStart(flame)
local smoke = emitterNew(glowNode)
emitterSetRate(smoke, 12)
emitterSetLife(smoke, 2.5, 4)
emitterSetSpeed(smoke, 0.4, 0.7)
emitterSetDirection(smoke, 0, 1, 0)
emitterSetSpread(smoke, 12)
emitterSetSize(smoke, 0.15, 0.8, 0.15)
emitterSetColor(smoke, 90, 80, 75, 90, 50, 45, 45, 0)
emitterSetSpin(smoke, -25, 25)
emitterStart(smoke)
braziers[i] = glowNode
end
-- Dust in the sunbeams over the atrium floor.
local air = nodeNew()
nodeSetPosition(air, 0, 3, 0)
local dust = emitterNew(air)
emitterSetRate(dust, 40)
emitterSetLife(dust, 5, 8)
emitterSetSpeed(dust, 0.05, 0.25)
emitterSetSpread(dust, 180)
emitterSetSize(dust, 0.05, 0.05)
emitterSetColor(dust, 255, 240, 210, 0, 255, 240, 210, 190)
emitterSetRadius(dust, 5)
emitterSetMax(dust, 400)
emitterStart(dust)
emitterBurst(dust, 300)
local camera = nodeNew()
cameraSet(camera)
cameraSetPerspective(65, 0.05, 100)
local stops = {
{ 10, 1.6, 0, -10, 2.5, 0 }, -- Ground floor, along the nave past the braziers
{ 1.2, 1.6, 5.2, -1, 3.5, -5 }, -- Across the atrium toward the far arcade
{ -9, 5.5, -1, 6, 3, 1 }, -- From the upper gallery
{ 5, 1.2, -1.5, -8, 2, 1.5 }, -- Low, past the columns and a brazier
}
function onOverlayUpdate()
local stop = stops[math.min(#stops, math.floor(frames / 45) + 1)]
frames = frames + 1
nodeSetPosition(camera, stop[1], stop[2], stop[3])
nodeLookAt(camera, stop[4], stop[5], stop[6])
overlayClear()
fontPrint(20, 20, "Sponza, frame " .. frames)
if frames == 40 or frames == 85 or frames == 130 or frames == 175 then
singeScreenshot()
end
if frames == 180 then
singeQuit()
end
end

View file

@ -2469,12 +2469,16 @@ EOF
my $i; my $i2; my $m; my $d; my $d2;
if ($unified_info{generate}->{$ddest}
&& $f =~ m/^(.*?)\|(.*)$/) {
$i = $1;
# Singe: resolve every path from the inclusion path as written. Reusing $i
# after cleanfile() made it relative to the build directory sent the source
# tree lookups through "../.." and created util/perl outside the tree when
# the build directory is nested two levels inside it.
my $inc = $1;
$m = $2;
$i = cleanfile($sourced, $i, $blddir);
$i2 = cleanfile($buildd, $i, $blddir);
$d = cleanfile($sourced, "$i/$m", $blddir);
$d2 = cleanfile($buildd, "$i/$m", $blddir);
$i = cleanfile($sourced, $inc, $blddir);
$i2 = cleanfile($buildd, $inc, $blddir);
$d = cleanfile($sourced, "$inc/$m", $blddir);
$d2 = cleanfile($buildd, "$inc/$m", $blddir);
} else {
$d = cleanfile($sourced, $f, $blddir);
$d2 = cleanfile($buildd, $f, $blddir);

37
util/packGlb.py Normal file
View file

@ -0,0 +1,37 @@
# Packs a .gltf with external buffers and images into one self-contained .glb, which is the only
# model format Singe loads. Usage: python3 util/packGlb.py Model.gltf Model.glb
import json, os, struct, sys
src, out = sys.argv[1], sys.argv[2]
base = os.path.dirname(src)
g = json.load(open(src))
binary = bytearray()
views = g.setdefault('bufferViews', [])
def add(data):
while len(binary) % 4: binary.append(0)
off = len(binary); binary.extend(data)
return off
# Buffers: concatenate, remapping views.
offsets = []
for b in g.get('buffers', []):
data = open(os.path.join(base, b['uri']), 'rb').read()
offsets.append(add(data))
for v in views:
v['byteOffset'] = v.get('byteOffset', 0) + offsets[v.get('buffer', 0)]
v['buffer'] = 0
# Images: each becomes a buffer view with a mime type.
for img in g.get('images', []):
uri = img.pop('uri')
data = open(os.path.join(base, uri), 'rb').read()
ext = uri.lower().rsplit('.', 1)[-1]
views.append({'buffer': 0, 'byteOffset': add(data), 'byteLength': len(data)})
img['bufferView'] = len(views) - 1
img['mimeType'] = 'image/png' if ext == 'png' else 'image/jpeg'
while len(binary) % 4: binary.append(0)
g['buffers'] = [{'byteLength': len(binary)}]
js = json.dumps(g, separators=(',', ':')).encode()
while len(js) % 4: js += b' '
with open(out, 'wb') as f:
f.write(struct.pack('<4sII', b'glTF', 2, 12 + 8 + len(js) + 8 + len(binary)))
f.write(struct.pack('<II', len(js), 0x4E4F534A)); f.write(js)
f.write(struct.pack('<II', len(binary), 0x004E4942)); f.write(binary)
print(out, os.path.getsize(out), 'bytes')