Compare commits

..

No commits in common. "master" and "ffms-video-player" have entirely different histories.

32082 changed files with 50619 additions and 9945365 deletions

90
.gitattributes vendored
View file

@ -1,90 +0,0 @@
#
# Image
#
*.xcf filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text
*.jpeg filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
*.gif filter=lfs diff=lfs merge=lfs -text
*.bmp filter=lfs diff=lfs merge=lfs -text
*.avif filter=lfs diff=lfs merge=lfs -text
*.tiff filter=lfs diff=lfs merge=lfs -text
*.ico filter=lfs diff=lfs merge=lfs -text
*.jxl filter=lfs diff=lfs merge=lfs -text
*.pcx filter=lfs diff=lfs merge=lfs -text
*.pnm filter=lfs diff=lfs merge=lfs -text
*.pgm filter=lfs diff=lfs merge=lfs -text
*.qoi filter=lfs diff=lfs merge=lfs -text
*.tga filter=lfs diff=lfs merge=lfs -text
*.webp filter=lfs diff=lfs merge=lfs -text
*.tif filter=lfs diff=lfs merge=lfs -text
*.jbg filter=lfs diff=lfs merge=lfs -text
#
# Other Binary
#
*.exe filter=lfs diff=lfs merge=lfs -text
*.dll filter=lfs diff=lfs merge=lfs -text
*.dat filter=lfs diff=lfs merge=lfs -text
#
# Singe game and menu descriptors are Lua text, not binary.
#
games.dat !filter !diff !merge text
menu.dat !filter !diff !merge text
#
# Fonts
#
*.ttf filter=lfs diff=lfs merge=lfs -text
#
# Audio
#
*.wav filter=lfs diff=lfs merge=lfs -text
*.flac filter=lfs diff=lfs merge=lfs -text
*.vgz filter=lfs diff=lfs merge=lfs -text
*.nsf filter=lfs diff=lfs merge=lfs -text
*.mp3 filter=lfs diff=lfs merge=lfs -text
*.mod filter=lfs diff=lfs merge=lfs -text
*.it filter=lfs diff=lfs merge=lfs -text
*.itz filter=lfs diff=lfs merge=lfs -text
*.MOD filter=lfs diff=lfs merge=lfs -text
*.med filter=lfs diff=lfs merge=lfs -text
*.xm filter=lfs diff=lfs merge=lfs -text
*.oxm filter=lfs diff=lfs merge=lfs -text
*.raw filter=lfs diff=lfs merge=lfs -text
*.ogg filter=lfs diff=lfs merge=lfs -text
*.opus filter=lfs diff=lfs merge=lfs -text
#
# Document
#
*.odt filter=lfs diff=lfs merge=lfs -text
*.pdf filter=lfs diff=lfs merge=lfs -text
*.docx filter=lfs diff=lfs merge=lfs -text
#
# Video
#
*.mkv filter=lfs diff=lfs merge=lfs -text
*.avi filter=lfs diff=lfs merge=lfs -text
*.mov filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -text
*.mpg filter=lfs diff=lfs merge=lfs -text
#
# Archive
#
*.bz2 filter=lfs diff=lfs merge=lfs -text
*.xz filter=lfs diff=lfs merge=lfs -text
*.lz filter=lfs diff=lfs merge=lfs -text
*.lzma filter=lfs diff=lfs merge=lfs -text
*.gz filter=lfs diff=lfs merge=lfs -text
*.tar filter=lfs diff=lfs merge=lfs -text
#
# Model
#
*.glb filter=lfs diff=lfs merge=lfs -text
*.stl filter=lfs diff=lfs merge=lfs -text

28
.gitignore vendored
View file

@ -1,24 +1,8 @@
*~ *~
**/.builddir/
**/.git-hidden/
thirdparty/**/.github/
cmake-build-debug/
*.log
*.user *.user
temp/ *.ISO
*# *.iso
build/ *.m4v
CMakeCache.txt *.index
CMakeFiles/ *.avi
*.a *.vob
*.o
docs/.asciidoctor/
.claude/settings.local.json
**/__pycache__/
# Extracted by the binary when it is run from here; never source.
/Singe/
/Menu.sh
/Menu.bat
/data/
screenshots/

1944
CHANGELOG

File diff suppressed because it is too large Load diff

View file

@ -1,892 +0,0 @@
#
# Singe 3
# Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public Licens
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program 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. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
#
cmake_minimum_required(VERSION 3.22)
project(singe2 VERSION 3.00 LANGUAGES C CXX)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
# The copyright end year tracks the build date so nothing has to be hand edited.
string(TIMESTAMP SINGE_COPYRIGHT_END_YEAR "%Y")
if(NOT DEFINED KANGAROO_OS)
set(KANGAROO_OS linux)
endif()
if(NOT DEFINED KANGAROO_ARCH)
set(KANGAROO_ARCH x86_64)
endif()
# Where the superbuild installs the third party libraries for this platform. SINGE_TREE lets a
# second toolchain for the same platform keep its own tree (the zig Linux build beside the gcc one).
if(NOT DEFINED SINGE_TREE)
set(SINGE_TREE ${KANGAROO_OS})
endif()
set(BUILD_DIR ${CMAKE_SOURCE_DIR}/.builddir/${SINGE_TREE}/${KANGAROO_ARCH})
set(GENERATED_DIR ${CMAKE_BINARY_DIR}/generated)
file(MAKE_DIRECTORY ${GENERATED_DIR})
# With SINGE_SUPERBUILD on (the presets' default) this configure builds the vendored libraries
# first and then Singe as a nested project; see cmake/Superbuild.cmake.
option(SINGE_SUPERBUILD "Build the vendored libraries, then Singe" OFF)
if(SINGE_SUPERBUILD)
include(cmake/Superbuild.cmake)
return()
endif()
# ===== Host tools needed to generate embedded resources =====
find_program(IMAGEMAGICK NAMES magick convert)
if(NOT IMAGEMAGICK)
message(FATAL_ERROR "ImageMagick (magick or convert) is required to render the embedded images.")
endif()
find_program(FFMPEG_TOOL NAMES ffmpeg)
if(NOT FFMPEG_TOOL)
message(FATAL_ERROR "ffmpeg is required to build the menu background video.")
endif()
find_program(LUA_TOOL NAMES lua5.4 lua)
if(NOT LUA_TOOL)
message(FATAL_ERROR "A Lua interpreter is required to generate the LuaSec option table.")
endif()
find_program(ASCIIDOCTOR_PDF NAMES asciidoctor-pdf)
if(NOT ASCIIDOCTOR_PDF)
message(FATAL_ERROR "asciidoctor-pdf is required to build the embedded manual (gem install asciidoctor-pdf rouge).")
endif()
find_program(ASCIIDOCTOR NAMES asciidoctor)
# ===== Generated sources =====
# Version header and Windows resources come from the project() line above.
configure_file(src/version.h.in ${GENERATED_DIR}/version.h @ONLY)
configure_file(src/singe.rc.in ${GENERATED_DIR}/singe.rc @ONLY)
# LuaSec needs its headers reachable as <luasocket/...>.
file(GLOB LUASOCKET_HEADERS thirdparty/luasocket/src/*.h)
file(COPY ${LUASOCKET_HEADERS} DESTINATION ${GENERATED_DIR}/include/luasocket)
# LuaSec option table generated from the OpenSSL headers that were built.
add_custom_command(
OUTPUT ${GENERATED_DIR}/luasec_options.c
COMMAND ${CMAKE_COMMAND} -DOUTPUT=${GENERATED_DIR}/luasec_options.c -DCOMMAND=${LUA_TOOL} "-DARGS=${CMAKE_SOURCE_DIR}/thirdparty/luasec/src/options.lua;-g;${BUILD_DIR}/include/openssl/ssl.h" -P ${CMAKE_SOURCE_DIR}/cmake/runToFile.cmake
DEPENDS thirdparty/luasec/src/options.lua ${BUILD_DIR}/include/openssl/ssl.h cmake/runToFile.cmake
COMMENT "Generating LuaSec options"
VERBATIM
)
# Embeds a file as a C array. The symbol name is derived from the file name, as xxd does.
set(EMBEDDED_HEADERS)
function(singeEmbed input output prefix)
get_filename_component(name ${input} NAME)
string(MAKE_C_IDENTIFIER "${prefix}${name}" symbol)
get_filename_component(guardName ${output} NAME)
string(MAKE_C_IDENTIFIER "${guardName}" guard)
string(TOUPPER "${guard}" guard)
add_custom_command(
OUTPUT ${output}
COMMAND ${CMAKE_COMMAND} -DINPUT=${input} -DOUTPUT=${output} -DGUARD=${guard} -DSYMBOL=${symbol} -DCOPYRIGHT_END_YEAR=${SINGE_COPYRIGHT_END_YEAR} -P ${CMAKE_SOURCE_DIR}/cmake/embed.cmake
DEPENDS ${input} cmake/embed.cmake
COMMENT "Embedding ${name}"
VERBATIM
)
set(EMBEDDED_HEADERS ${EMBEDDED_HEADERS} ${output} PARENT_SCOPE)
endfunction()
# The canvas size of a GIMP image, from the XCF header (ImageMagick only sees the layers, and its
# flatten crops to the first one).
function(singeXcfCanvas xcf widthVar heightVar)
file(READ ${xcf} header LIMIT 22 HEX)
string(SUBSTRING ${header} 28 8 widthHex)
string(SUBSTRING ${header} 36 8 heightHex)
math(EXPR width "0x${widthHex}")
math(EXPR height "0x${heightHex}")
set(${widthVar} ${width} PARENT_SCOPE)
set(${heightVar} ${height} PARENT_SCOPE)
endfunction()
# Renders assets/<source>.xcf as <name>.png the way GIMP would: every layer at its offset onto the
# image's own canvas, anything outside it clipped.
function(singeRenderImage name source)
singeXcfCanvas(${CMAKE_SOURCE_DIR}/assets/${source}.xcf width height)
add_custom_command(
OUTPUT ${GENERATED_DIR}/${name}.png
COMMAND ${IMAGEMAGICK} -background "rgba(0,0,0,0)" -size ${width}x${height} xc:none ${CMAKE_SOURCE_DIR}/assets/${source}.xcf -flatten ${GENERATED_DIR}/${name}.png
DEPENDS assets/${source}.xcf
COMMENT "Rendering ${name}.png (${width}x${height})"
VERBATIM
)
endfunction()
# Renders a layered image and embeds the PNG.
function(singeEmbedImage name)
singeRenderImage(${name} ${name})
singeEmbed(${GENERATED_DIR}/${name}.png ${GENERATED_DIR}/${name}.h "")
set(EMBEDDED_HEADERS ${EMBEDDED_HEADERS} PARENT_SCOPE)
endfunction()
# Embeds a Lua module from the third party tree.
function(singeEmbedLua path prefix)
get_filename_component(name ${path} NAME_WE)
singeEmbed(${CMAKE_SOURCE_DIR}/${path} ${GENERATED_DIR}/${prefix}${name}_lua.h "${prefix}")
set(EMBEDDED_HEADERS ${EMBEDDED_HEADERS} PARENT_SCOPE)
endfunction()
# Images
singeEmbedImage(font)
# The icon is drawn large; the window gets a 128 pixel copy and the Windows .ico the usual sizes,
# both cut from a square, transparent-padded render so a non-square source keeps its proportions.
set(ICON_SIZE 1024)
singeRenderImage(iconFull icon)
add_custom_command(
OUTPUT ${GENERATED_DIR}/iconSquare.png
COMMAND ${IMAGEMAGICK} ${GENERATED_DIR}/iconFull.png -background "rgba(0,0,0,0)" -resize "${ICON_SIZE}x${ICON_SIZE}>" -gravity center -extent ${ICON_SIZE}x${ICON_SIZE} ${GENERATED_DIR}/iconSquare.png
DEPENDS ${GENERATED_DIR}/iconFull.png
COMMENT "Rendering iconSquare.png"
VERBATIM
)
add_custom_command(
OUTPUT ${GENERATED_DIR}/icon.png
COMMAND ${IMAGEMAGICK} ${GENERATED_DIR}/iconSquare.png -resize 128x128 ${GENERATED_DIR}/icon.png
DEPENDS ${GENERATED_DIR}/iconSquare.png
COMMENT "Rendering icon.png"
VERBATIM
)
singeEmbed(${GENERATED_DIR}/icon.png ${GENERATED_DIR}/icon.h "")
singeEmbedImage(kangarooPunchLogo)
# The picture shown where a game's art should be. Drawn here rather than kept as a file: it is
# ours, it needs no licence, and it cannot drift from what the code expects. Loud on purpose --
# a blank space reads as a layout mistake, this reads as "your path is wrong".
add_custom_command(
OUTPUT ${GENERATED_DIR}/missing.png
COMMAND ${IMAGEMAGICK} -size 256x256 pattern:checkerboard -auto-level -fill "#c0208090" -colorize 60
-fill "#ffd84a" -pointsize 34 -gravity center -annotate +0-14 "MISSING"
-fill "#ffffffcc" -pointsize 18 -annotate +0+22 "art"
-bordercolor "#ffd84a" -border 4 ${GENERATED_DIR}/missing.png
COMMENT "Drawing missing.png"
VERBATIM
)
singeEmbed(${GENERATED_DIR}/missing.png ${GENERATED_DIR}/missing_png.h "")
singeEmbedImage(singeLogo)
# Windows icon for the resource file.
add_custom_command(
OUTPUT ${GENERATED_DIR}/icon.ico
COMMAND ${IMAGEMAGICK} ${GENERATED_DIR}/iconSquare.png -define icon:auto-resize=256,64,48,32,16 ${GENERATED_DIR}/icon.ico
DEPENDS ${GENERATED_DIR}/iconSquare.png
COMMENT "Rendering icon.ico"
VERBATIM
)
# Support files extracted at first run.
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Framework.singe ${GENERATED_DIR}/Framework_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/controls.cfg ${GENERATED_DIR}/controls_cfg.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/settings.cfg ${GENERATED_DIR}/settings_cfg.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.singe ${GENERATED_DIR}/Menu_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Tools.singe ${GENERATED_DIR}/Tools_singe.h "")
# Forge, the authoring tool, is built from assets/Forge but is NOT embedded: it is distributed on
# its own, not with the engine. Nothing of it -- not the editor, not the compiler, not the runtime
# a game it builds loads -- belongs inside Singe.
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Net.singe ${GENERATED_DIR}/Net_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/cacerts.pem ${GENERATED_DIR}/cacerts_pem.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Master.singe ${GENERATED_DIR}/Master_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Backdrop.singe ${GENERATED_DIR}/Backdrop_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/MenuDocument.singe ${GENERATED_DIR}/MenuDocument_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/MenuOverlay.singe ${GENERATED_DIR}/MenuOverlay_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.rml ${GENERATED_DIR}/Menu_rml.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/menu.rcss ${GENERATED_DIR}/menu_rcss.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/gui.rcss ${GENERATED_DIR}/gui_rcss.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/scoreBezel.rml ${GENERATED_DIR}/scoreBezel_rml.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/scoreBezel.rcss ${GENERATED_DIR}/scoreBezel_rcss.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/subtitle.rml ${GENERATED_DIR}/subtitle_rml.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/subtitle.rcss ${GENERATED_DIR}/subtitle_rcss.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/FreeSansBold.ttf ${GENERATED_DIR}/FreeSansBold_ttf.h "")
# The two models the menu's intro is made of. Singe's own branding, so they belong with the engine
# the way the font and the logos do -- the menu cannot reach into a game directory for them.
singeEmbed(${CMAKE_SOURCE_DIR}/assets/DragonModel.glb ${GENERATED_DIR}/DragonModel_glb.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/SingeText.glb ${GENERATED_DIR}/SingeText_glb.h "")
# Menu background video: a recording of the backdrop the menu draws, for the machines that cannot
# draw it. It is not built here -- rendering it needs a GPU device and the engine itself, which is
# the wrong way round for a build -- so it is an asset like the models are, made by
# util/renderMenuVideo.py and re-made whenever assets/Backdrop.singe changes.
singeEmbed(${CMAKE_SOURCE_DIR}/assets/menuBackground.mkv ${GENERATED_DIR}/menuBackground_mkv.h "")
# The same sound the video carries, for the renderer that draws the backdrop instead of playing it.
# util/renderMenuVideo.py writes the two together.
singeEmbed(${CMAKE_SOURCE_DIR}/assets/menuIntro.flac ${GENERATED_DIR}/menuIntro_flac.h "")
# Calibration click for the menu's audio delay screen: 20 ms of white noise with a fast fade.
add_custom_command(
OUTPUT ${GENERATED_DIR}/click.wav
COMMAND ${FFMPEG_TOOL} -y -loglevel error -f lavfi -i "anoisesrc=d=0.02:c=white:r=44100:a=0.6" -af "afade=t=out:st=0:d=0.02:curve=exp" -ac 1 -c:a pcm_s16le ${GENERATED_DIR}/click.wav
COMMENT "Building click.wav"
VERBATIM
)
singeEmbed(${GENERATED_DIR}/click.wav ${GENERATED_DIR}/click_wav.h "")
# Manual, rendered from the AsciiDoc source into the top-level build directory beside the binaries
# (build-docs.sh writes the same files) and shipped inside the binary.
set(MANUAL_DIR ${CMAKE_SOURCE_DIR}/.builddir)
add_custom_command(
OUTPUT ${MANUAL_DIR}/Manual.pdf
COMMAND ${CMAKE_COMMAND} -E make_directory ${MANUAL_DIR}
COMMAND ${ASCIIDOCTOR_PDF} -a revnumber=${PROJECT_VERSION} ${CMAKE_SOURCE_DIR}/docs/Manual.adoc -o ${MANUAL_DIR}/Manual.pdf
DEPENDS docs/Manual.adoc
COMMENT "Rendering Manual.pdf"
VERBATIM
)
singeEmbed(${MANUAL_DIR}/Manual.pdf ${GENERATED_DIR}/Manual_pdf.h "")
# The licence Singe is under and the notices its third-party libraries and assets require. Both
# have to reach whoever gets the binary, so they travel inside it like the manual does.
singeEmbed(${CMAKE_SOURCE_DIR}/COPYING ${GENERATED_DIR}/COPYING.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/LICENSES ${GENERATED_DIR}/LICENSES.h "")
# Lua libraries
singeEmbedLua(thirdparty/luasocket/src/ftp.lua "")
singeEmbedLua(thirdparty/luasocket/src/headers.lua "")
singeEmbedLua(thirdparty/luasocket/src/http.lua "")
singeEmbedLua(thirdparty/luasocket/src/ltn12.lua "")
singeEmbedLua(thirdparty/luasocket/src/mbox.lua "")
singeEmbedLua(thirdparty/luasocket/src/mime.lua "")
singeEmbedLua(thirdparty/luasocket/src/smtp.lua "")
singeEmbedLua(thirdparty/luasocket/src/socket.lua "")
singeEmbedLua(thirdparty/luasocket/src/tp.lua "")
singeEmbedLua(thirdparty/luasocket/src/url.lua "")
singeEmbedLua(thirdparty/luasec/src/https.lua "")
singeEmbedLua(thirdparty/luasec/src/ssl.lua "")
singeEmbedLua(thirdparty/librs232/bindings/lua/rs232.lua "")
singeEmbedLua(thirdparty/copas/src/copas.lua "")
singeEmbedLua(thirdparty/copas/src/copas/ftp.lua "copas_")
singeEmbedLua(thirdparty/copas/src/copas/future.lua "copas_")
singeEmbedLua(thirdparty/copas/src/copas/http.lua "copas_")
singeEmbedLua(thirdparty/copas/src/copas/smtp.lua "copas_")
singeEmbedLua(thirdparty/copas/src/copas/lock.lua "copas_")
singeEmbedLua(thirdparty/copas/src/copas/queue.lua "copas_")
singeEmbedLua(thirdparty/copas/src/copas/semaphore.lua "copas_")
singeEmbedLua(thirdparty/copas/src/copas/timer.lua "copas_")
singeEmbedLua(thirdparty/binaryheap.lua/src/binaryheap.lua "")
singeEmbedLua(thirdparty/timerwheel.lua/src/timerwheel/timerwheel.lua "")
singeEmbedLua(thirdparty/json.lua/json.lua "")
singeEmbedLua(thirdparty/middleclass/middleclass.lua "")
singeEmbedLua(thirdparty/lume/lume.lua "")
singeEmbedLua(thirdparty/inspect.lua/inspect.lua "")
singeEmbedLua(thirdparty/bump.lua/bump.lua "")
# Forge's manual, rendered the same way. It is not embedded: the superbuild packs it into
# Forge.game (cmake/Superbuild.cmake), which is the one thing that ships Forge.
# Its vocabulary tables are generated from the manifest in Author.singe, so the manual cannot
# drift from it; the generated file is kept in the tree for a host without a Lua interpreter.
find_program(LUA_INTERPRETER NAMES lua5.4 lua)
if(LUA_INTERPRETER)
add_custom_command(
OUTPUT ${CMAKE_SOURCE_DIR}/docs/ForgeVocabulary.adoc
COMMAND ${CMAKE_COMMAND} -E chdir ${CMAKE_SOURCE_DIR} ${LUA_INTERPRETER} util/forgeVocabulary.lua > ${CMAKE_SOURCE_DIR}/docs/ForgeVocabulary.adoc
DEPENDS assets/Forge/Author.singe util/forgeVocabulary.lua
COMMENT "Writing the Forge vocabulary"
)
endif()
# The book embeds the tutorials' pictures and includes their shipped descriptions, so any of
# those changing renders it again; the globs are re-run on every build.
file(GLOB_RECURSE FORGE_BOOK_PARTS CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/docs/images/tutorials/* ${CMAKE_SOURCE_DIR}/assets/Forge/tutorials/*.game)
add_custom_command(
OUTPUT ${MANUAL_DIR}/Forge.pdf
COMMAND ${CMAKE_COMMAND} -E make_directory ${MANUAL_DIR}
COMMAND ${ASCIIDOCTOR_PDF} -a revnumber=${PROJECT_VERSION} ${CMAKE_SOURCE_DIR}/docs/Forge.adoc -o ${MANUAL_DIR}/Forge.pdf
DEPENDS docs/Forge.adoc docs/ForgeVocabulary.adoc ${FORGE_BOOK_PARTS}
COMMENT "Rendering Forge.pdf"
VERBATIM
)
add_custom_target(forgeManual ALL DEPENDS ${MANUAL_DIR}/Forge.pdf)
# Optional HTML manual for browsing: cmake --build . --target docs
if(ASCIIDOCTOR)
add_custom_target(docs
COMMAND ${ASCIIDOCTOR} -a revnumber=${PROJECT_VERSION} ${CMAKE_SOURCE_DIR}/docs/Manual.adoc -o ${MANUAL_DIR}/Manual.html
DEPENDS ${MANUAL_DIR}/Manual.pdf
COMMENT "Rendering Manual.html"
VERBATIM
)
endif()
# ===== Sources =====
set(SINGE_SOURCE
src/collide.c
src/collide.h
src/common.h
src/decode.c
src/decode.h
src/embedded.h
src/frameFile.c
src/rotoZoom.c
src/rotoZoom.h
src/frameFile.h
src/main.c
src/main.h
src/pack.c
src/pack.h
src/particles.c
src/particles.h
src/persist.c
src/persist.h
src/scheduler.c
src/scheduler.h
src/stats.c
src/stats.h
src/physics.h
src/navRecast.cpp
src/physicsJolt.cpp
src/gui.cpp
src/gui.h
src/guiRender.cpp
src/guiRender.h
src/hdr.c
src/ktx2.h
src/ktx2Basis.cpp
thirdparty/basis_universal/transcoder/basisu_transcoder.cpp
src/model.c
src/model.h
src/math3d.c
src/math3d.h
src/midi.c
src/midi.h
src/midiIo.c
src/midiIo.h
thirdparty/tinysoundfont/tsf.h
thirdparty/tinysoundfont/tml.h
src/render.c
src/render.h
src/renderGles.c
src/renderGlesApi.h
src/renderGlesLoad.c
src/renderGpu.c
src/scene.c
src/scene.h
src/singe.c
src/singe.h
src/stddclmr.h
src/util.c
src/util.h
src/videoPlayer.c
src/videoPlayer.h
src/vfs.c
src/vfs.h
)
# The binding reports this from sqlite3.lversion(); it is the version of thirdparty/lsqlite3/lsqlite3.c.
set(LSQLITE_VERSION "0.9.7")
set(SQLITE_SOURCE
thirdparty/sqlite/sqlite3.c
thirdparty/sqlite/sqlite3.h
thirdparty/lsqlite3/lsqlite3.c
)
set_source_files_properties(thirdparty/lsqlite3/lsqlite3.c PROPERTIES COMPILE_DEFINITIONS "LSQLITE_VERSION=\"${LSQLITE_VERSION}\"")
set(ARG_PARSER_SOURCE
thirdparty/arg_parser/carg_parser.c
thirdparty/arg_parser/carg_parser.h
)
file(GLOB LUA_SOURCE thirdparty/lua/src/*.c thirdparty/lua/src/*.h)
list(FILTER LUA_SOURCE EXCLUDE REGEX "/(lua|luac|onelua)\\.c$")
set(LUA_FILESYSTEM_SOURCE
thirdparty/luafilesystem/src/lfs.c
thirdparty/luafilesystem/src/lfs.h
)
# The PUC-Rio md5 library, as Hypseus bundles it (its sumhexa is named hexsum), so the games that
# require("md5") get the same five functions with the same behaviour.
set(LUA_MD5_SOURCE
thirdparty/md5/md5.c
thirdparty/md5/md5.h
thirdparty/md5/md5lib.c
)
# The rest of the Lua modules Hypseus exposes, so a game written against it finds them here too:
# LuaBitOp, lrandom, Lua CJSON and LPeg.
set(LUA_BITOP_SOURCE
thirdparty/luabitop/lbit.c
)
set(LUA_RANDOM_SOURCE
thirdparty/lrandom/lrandom.c
)
set(LUA_CJSON_SOURCE
thirdparty/lua-cjson/fpconv.c
thirdparty/lua-cjson/fpconv.h
thirdparty/lua-cjson/lua_cjson.c
thirdparty/lua-cjson/strbuf.c
thirdparty/lua-cjson/strbuf.h
)
set(LPEG_SOURCE
thirdparty/lpeg/lpcap.c
thirdparty/lpeg/lpcap.h
thirdparty/lpeg/lpcode.c
thirdparty/lpeg/lpcode.h
thirdparty/lpeg/lpcset.c
thirdparty/lpeg/lpcset.h
thirdparty/lpeg/lpprint.c
thirdparty/lpeg/lpprint.h
thirdparty/lpeg/lptree.c
thirdparty/lpeg/lptree.h
thirdparty/lpeg/lptypes.h
thirdparty/lpeg/lpvm.c
thirdparty/lpeg/lpvm.h
)
set(LUA_SOCKET_SOURCE
thirdparty/luasocket/src/auxiliar.c
thirdparty/luasocket/src/auxiliar.h
thirdparty/luasocket/src/buffer.c
thirdparty/luasocket/src/buffer.h
thirdparty/luasocket/src/compat.c
thirdparty/luasocket/src/compat.h
thirdparty/luasocket/src/except.c
thirdparty/luasocket/src/except.h
thirdparty/luasocket/src/inet.c
thirdparty/luasocket/src/inet.h
thirdparty/luasocket/src/io.c
thirdparty/luasocket/src/io.h
thirdparty/luasocket/src/luasocket.c
thirdparty/luasocket/src/luasocket.h
thirdparty/luasocket/src/mime.c
thirdparty/luasocket/src/mime.h
thirdparty/luasocket/src/options.c
thirdparty/luasocket/src/options.h
thirdparty/luasocket/src/select.c
thirdparty/luasocket/src/select.h
thirdparty/luasocket/src/socket.h
thirdparty/luasocket/src/tcp.c
thirdparty/luasocket/src/tcp.h
thirdparty/luasocket/src/timeout.c
thirdparty/luasocket/src/timeout.h
thirdparty/luasocket/src/udp.c
thirdparty/luasocket/src/udp.h
)
if(WIN32)
list(APPEND LUA_SOCKET_SOURCE
thirdparty/luasocket/src/wsocket.c
thirdparty/luasocket/src/wsocket.h
)
else()
list(APPEND LUA_SOCKET_SOURCE
thirdparty/luasocket/src/serial.c
thirdparty/luasocket/src/unix.c
thirdparty/luasocket/src/unix.h
thirdparty/luasocket/src/unixdgram.c
thirdparty/luasocket/src/unixdgram.h
thirdparty/luasocket/src/unixstream.c
thirdparty/luasocket/src/unixstream.h
thirdparty/luasocket/src/usocket.c
thirdparty/luasocket/src/usocket.h
)
endif()
set(LUASEC_SOURCE
${GENERATED_DIR}/luasec_options.c
thirdparty/luasec/src/compat.h
thirdparty/luasec/src/config.c
thirdparty/luasec/src/context.c
thirdparty/luasec/src/context.h
thirdparty/luasec/src/ec.c
thirdparty/luasec/src/ec.h
thirdparty/luasec/src/options.h
thirdparty/luasec/src/ssl.c
thirdparty/luasec/src/ssl.h
thirdparty/luasec/src/x509.c
thirdparty/luasec/src/x509.h
)
set(LUA_RS232_SOURCE
thirdparty/librs232/bindings/lua/luars232.c
thirdparty/librs232/src/rs232.c
)
if(WIN32)
list(APPEND LUA_RS232_SOURCE thirdparty/librs232/src/rs232_windows.c)
else()
list(APPEND LUA_RS232_SOURCE thirdparty/librs232/src/rs232_posix.c)
endif()
set(UTHASH_SOURCE
thirdparty/uthash/src/uthash.h
thirdparty/uthash/src/utlist.h
)
set(MANYMOUSE_SOURCE
thirdparty/manymouse/linux_evdev.c
thirdparty/manymouse/macosx_hidmanager.c
thirdparty/manymouse/macosx_hidutilities.c
thirdparty/manymouse/manymouse.c
thirdparty/manymouse/manymouse.h
thirdparty/manymouse/windows_wminput.c
thirdparty/manymouse/x11_xinput2.c
)
# ===== Target =====
add_executable(${CMAKE_PROJECT_NAME}
${SINGE_SOURCE}
${EMBEDDED_HEADERS}
${GENERATED_DIR}/version.h
${ARG_PARSER_SOURCE}
${LUA_SOURCE}
${LUA_FILESYSTEM_SOURCE}
${LUA_MD5_SOURCE}
${LUA_BITOP_SOURCE}
${LUA_RANDOM_SOURCE}
${LUA_CJSON_SOURCE}
${LPEG_SOURCE}
${SQLITE_SOURCE}
${LUA_SOCKET_SOURCE}
${LUASEC_SOURCE}
${LUA_RS232_SOURCE}
${UTHASH_SOURCE}
${MANYMOUSE_SOURCE}
)
if(WIN32)
if(SINGE_ZIG_TARGET)
# CMake archives every object into objects.a for MinGW links, and zig's linker cannot take a
# compiled resource from inside an archive; compiled on its own it links as a plain input.
add_custom_command(
OUTPUT ${GENERATED_DIR}/singe.res
COMMAND ${CMAKE_RC_COMPILER} /fo ${GENERATED_DIR}/singe.res ${GENERATED_DIR}/singe.rc
DEPENDS ${GENERATED_DIR}/singe.rc ${GENERATED_DIR}/icon.ico
COMMENT "Compiling resources"
VERBATIM
)
add_custom_target(singeResources DEPENDS ${GENERATED_DIR}/singe.res)
add_dependencies(${CMAKE_PROJECT_NAME} singeResources)
target_link_options(${CMAKE_PROJECT_NAME} PRIVATE ${GENERATED_DIR}/singe.res)
else()
enable_language(RC)
target_sources(${CMAKE_PROJECT_NAME} PRIVATE ${GENERATED_DIR}/singe.rc ${GENERATED_DIR}/icon.ico)
endif()
endif()
# Output name matches the release artifact: Singe-v3.00-Linux-x86_64
string(SUBSTRING ${KANGAROO_OS} 0 1 osInitial)
string(SUBSTRING ${KANGAROO_OS} 1 -1 osRest)
string(TOUPPER ${osInitial} osInitial)
set_target_properties(${CMAKE_PROJECT_NAME} PROPERTIES OUTPUT_NAME "Singe-v${PROJECT_VERSION}-${osInitial}${osRest}-${KANGAROO_ARCH}")
# Warnings apply to our code only; the vendored libraries are not ours to fix.
set_source_files_properties(${SINGE_SOURCE} PROPERTIES COMPILE_OPTIONS "-Wall;-Wextra")
# librs232 stamps its build date into the module; clang under zig makes that an error by default.
set_source_files_properties(thirdparty/librs232/bindings/lua/luars232.c PROPERTIES COMPILE_OPTIONS "-Wno-date-time")
# The Basis transcoder is warning-heavy third-party C++; it is compiled quietly.
set_source_files_properties(thirdparty/basis_universal/transcoder/basisu_transcoder.cpp PROPERTIES COMPILE_OPTIONS "-w")
target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE
RS232_STATIC
BASISD_SUPPORT_KTX2=1
BASISD_SUPPORT_KTX2_ZSTD=1
BASISD_SUPPORT_PVRTC1=0
BASISD_SUPPORT_PVRTC2=0
BASISD_SUPPORT_ATC=0
BASISD_SUPPORT_FXT1=0
BASISD_SUPPORT_ETC2_EAC_A8=0
BASISD_SUPPORT_ETC2_EAC_RG11=0
SQLITE_DQS=0
SQLITE_THREADSAFE=1
SQLITE_DEFAULT_MEMSTATUS=0
SQLITE_OMIT_SHARED_CACHE
)
if(WIN32)
# _WIN32_WINNT=0x0600 sets the minimum compatible version of Windows to Vista.
# The function inet_pton() does not exist before then.
target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE
_WIN32_WINNT=0x0600
)
endif()
target_compile_options(${CMAKE_PROJECT_NAME} PRIVATE
-Wno-deprecated-declarations
)
target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE
${CMAKE_BINARY_DIR}
${GENERATED_DIR}/include
${BUILD_DIR}
${BUILD_DIR}/include
thirdparty/lua/src
thirdparty/sqlite
thirdparty/luasec/src
thirdparty/librs232/include
thirdparty/basis_universal/transcoder
)
target_link_directories(${CMAKE_PROJECT_NAME} PRIVATE
${BUILD_DIR}/lib
)
# ===== Libraries =====
# Platform specific system libraries.
if(KANGAROO_OS STREQUAL "linux")
if(KANGAROO_ARCH MATCHES "^(aarch64|armhf)$")
set(SYSTEM_LIBS -ldl)
# An ARM board decodes video through its V4L2 memory-to-memory device (videoPlayer.c).
# Nothing here is particular to a Raspberry Pi: any board whose kernel offers one is served.
target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE SINGE_V4L2_DECODE)
# The stateless decoders are a separate interface again, and FFmpeg builds them only when its
# own dependencies were met, so the engine follows what FFmpeg actually did.
if(EXISTS ${BUILD_DIR}/build/ffmpeg/config.h)
file(READ ${BUILD_DIR}/build/ffmpeg/config.h ffmpegConfig)
if(ffmpegConfig MATCHES "#define CONFIG_V4L2_REQUEST 1")
target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE SINGE_V4L2_REQUEST_DECODE)
message(STATUS "FFmpeg carries the V4L2 stateless decoders; enabling them in the player")
endif()
endif()
else()
# ffmpeg's vdpau hardware context is always compiled in on X11 hosts.
set(SYSTEM_LIBS -lX11 -lvdpau -ldl -rdynamic) # -rdynamic gives the crash backtrace function names
endif()
# Release binaries are stripped; the crash backtrace reads the dynamic symbols -rdynamic exports,
# which stripping keeps.
if(CMAKE_BUILD_TYPE STREQUAL "Release")
list(APPEND SYSTEM_LIBS -s)
endif()
elseif(KANGAROO_OS STREQUAL "macos")
set(SYSTEM_LIBS
-Wl,-framework,CoreVideo
-Wl,-framework,Cocoa
-Wl,-framework,IOKit
-Wl,-framework,ForceFeedback
-Wl,-framework,Carbon
-Wl,-framework,CoreAudio
-Wl,-framework,AudioToolbox
-Wl,-framework,AVFoundation
-Wl,-framework,Foundation
-Wl,-weak_framework,GameController
-Wl,-weak_framework,Metal
-Wl,-weak_framework,QuartzCore
-Wl,-weak_framework,CoreHaptics
-Wl,-weak_framework,CoreMIDI
-Wl,-weak_framework,UniformTypeIdentifiers
-liconv
-lc++
)
if(NOT SINGE_ZIG_TARGET)
# osxcross builds need clang's runtime library named; zig carries its own.
if(NOT DEFINED OSXCROSS_TARGET_DIR)
message(FATAL_ERROR "OSXCROSS_TARGET_DIR must be set for macOS builds without zig.")
endif()
list(APPEND SYSTEM_LIBS ${OSXCROSS_TARGET_DIR}/darwin/libclang_rt.osx.a)
endif()
elseif(KANGAROO_OS STREQUAL "windows")
# A GUI-subsystem executable, so no console window opens; zig's linker wants the flag spelled out.
if(SINGE_ZIG_TARGET)
set(windowsSubsystem -Wl,--subsystem,windows)
else()
set(windowsSubsystem -mwindows)
endif()
set(SYSTEM_LIBS
${windowsSubsystem}
-static
-lmingw32
-latomic
-lole32
-luser32
-lkernel32
-lgdi32
-lwinmm
-limm32
-loleaut32
-lversion
-luuid
-lrpcrt4
-ladvapi32
-lsetupapi
-lshell32
-ldinput8
-lws2_32
-lbcrypt
-lcrypt32
)
if(NOT SINGE_ZIG_TARGET)
# MinGW's stack protector runtime; zig's compiler-rt carries it itself.
list(APPEND SYSTEM_LIBS -lssp)
endif()
else()
message(FATAL_ERROR "Unknown KANGAROO_OS: ${KANGAROO_OS}")
endif()
# FFmpeg's hardware decoding backends (VA-API, VDPAU, DRM, V4L2, VideoToolbox) are whatever the
# superbuild configured; its pkg-config files name the system libraries and frameworks they need, so
# read them back.
if(NOT KANGAROO_OS STREQUAL "windows")
foreach(pc libavutil libavcodec)
set(pcFile ${BUILD_DIR}/lib/pkgconfig/${pc}.pc)
if(EXISTS ${pcFile})
file(STRINGS ${pcFile} pcLibs REGEX "^Libs:")
string(REGEX REPLACE "^Libs: *" "" pcLibs "${pcLibs}")
separate_arguments(pcLibs)
set(framework FALSE)
foreach(token ${pcLibs})
if(framework)
list(APPEND SYSTEM_LIBS -Wl,-framework,${token})
set(framework FALSE)
elseif(token STREQUAL "-framework")
set(framework TRUE)
elseif(token MATCHES "^-l" AND NOT token MATCHES "^-l(avutil|avcodec|z|m|atomic|pthread|iconv)$")
list(APPEND SYSTEM_LIBS ${token})
endif()
endforeach()
endif()
endforeach()
list(REMOVE_DUPLICATES SYSTEM_LIBS)
endif()
# Static third party libraries built by build-all.sh.
set(STATIC_LIBS
${BUILD_DIR}/lib/libavcodec.a
${BUILD_DIR}/lib/libavdevice.a
${BUILD_DIR}/lib/libavfilter.a
${BUILD_DIR}/lib/libavformat.a
${BUILD_DIR}/lib/libavutil.a
${BUILD_DIR}/lib/libfreetype.a
${BUILD_DIR}/lib/libopus.a
${BUILD_DIR}/lib/libopusfile.a
${BUILD_DIR}/lib/libogg.a
${BUILD_DIR}/lib/libSDL3.a
${BUILD_DIR}/lib/libSDL3_image.a
${BUILD_DIR}/lib/libSDL3_mixer.a
${BUILD_DIR}/lib/libSDL3_ttf.a
${BUILD_DIR}/lib/libswresample.a
${BUILD_DIR}/lib/libswscale.a
${BUILD_DIR}/lib/libwavpack.a
${BUILD_DIR}/lib/libwebp.a
${BUILD_DIR}/lib/libwebpdemux.a
${BUILD_DIR}/lib/libwebpmux.a
${BUILD_DIR}/lib/libsharpyuv.a
${BUILD_DIR}/lib/libxmp.a
${BUILD_DIR}/lib/libz.a
${BUILD_DIR}/lib/libzstd.a
${BUILD_DIR}/lib/libcrypto.a
${BUILD_DIR}/lib/libssl.a
)
# Not every archive exists in every tree: SDL3_image builds its own jpeg and png decoders where the
# platform has none (macOS uses ImageIO for jpeg), brotli arrives with FreeType's WOFF2 support, and
# libgme with the chiptune decoder. Link whichever ones are there.
foreach(optionalLib libjpeg.a libpng16.a libbrotlidec.a libbrotlicommon.a libgme.a libdav1d.a libjxl_dec.a libhwy.a)
if(EXISTS ${BUILD_DIR}/lib/${optionalLib})
list(APPEND STATIC_LIBS ${BUILD_DIR}/lib/${optionalLib})
endif()
endforeach()
# The scene shaders: src/shaders/scene.hlsl compiled at build time into a header of SPIR-V, DXIL
# and MSL blobs by SDL_shadercross, which the superbuild builds for the host (cmake/hostTools.cmake)
# and names in SINGE_SHADERCROSS; configuring this project alone finds one on the PATH.
if(NOT SINGE_SHADERCROSS)
find_program(SINGE_SHADERCROSS shadercross)
if(NOT SINGE_SHADERCROSS)
message(FATAL_ERROR "SDL_shadercross is needed to compile the scene shaders; build through the superbuild (cmake --preset ...) or set SINGE_SHADERCROSS.")
endif()
endif()
# SPIRV-Cross turns the same SPIR-V into GLSL ES for the GLES backend (PLAN.md section 56). Unlike
# shadercross this one is not fatal when missing: the header then carries null ES strings, the GLES
# backend declines to start, and every other platform builds exactly as before.
if(NOT SINGE_SPIRV_CROSS)
find_program(SINGE_SPIRV_CROSS spirv-cross)
endif()
if(NOT SINGE_SPIRV_CROSS)
message(WARNING "spirv-cross was not found; this build will have no OpenGL ES shaders and so no GLES backend.")
endif()
set(shaderHeader ${CMAKE_BINARY_DIR}/generated/shaders/sceneShaders.h)
add_custom_command(
OUTPUT ${shaderHeader}
COMMAND ${CMAKE_COMMAND} -DSHADERCROSS=${SINGE_SHADERCROSS} -DSPIRVCROSS=${SINGE_SPIRV_CROSS} -DSOURCE=${CMAKE_SOURCE_DIR}/src/shaders/scene.hlsl -DOUTPUT=${shaderHeader} -P ${CMAKE_SOURCE_DIR}/cmake/shaderHeader.cmake
DEPENDS ${CMAKE_SOURCE_DIR}/src/shaders/scene.hlsl ${CMAKE_SOURCE_DIR}/src/sceneShared.h ${CMAKE_SOURCE_DIR}/cmake/shaderHeader.cmake
COMMENT "Compiling the scene shaders"
)
target_sources(${CMAKE_PROJECT_NAME} PRIVATE ${shaderHeader})
# The GUI shaders the same way: src/shaders/gui.hlsl into guiShaders.h for guiRender.cpp. The
# entry list is a CMake list, so it is quoted as one argument and VERBATIM keeps the semicolons.
set(guiShaderHeader ${CMAKE_BINARY_DIR}/generated/shaders/guiShaders.h)
set(guiShaderEntries "guiVertex:vertex;guiFragmentColor:fragment;guiFragmentTexture:fragment;guiFragmentFilter:fragment;guiFragmentBlur:fragment;guiFragmentColorMatrix:fragment;guiFragmentGradient:fragment")
add_custom_command(
OUTPUT ${guiShaderHeader}
COMMAND ${CMAKE_COMMAND} -DSHADERCROSS=${SINGE_SHADERCROSS} -DSPIRVCROSS=${SINGE_SPIRV_CROSS} -DSOURCE=${CMAKE_SOURCE_DIR}/src/shaders/gui.hlsl -DOUTPUT=${guiShaderHeader} "-DENTRIES=${guiShaderEntries}" -DPREFIX=guiShader -DTYPE=GuiShaderT -P ${CMAKE_SOURCE_DIR}/cmake/shaderHeader.cmake
DEPENDS ${CMAKE_SOURCE_DIR}/src/shaders/gui.hlsl ${CMAKE_SOURCE_DIR}/cmake/shaderHeader.cmake
COMMENT "Compiling the GUI shaders"
VERBATIM
)
target_sources(${CMAKE_PROJECT_NAME} PRIVATE ${guiShaderHeader})
target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_BINARY_DIR}/generated)
# Rockchip's hardware decoders are reached by name (videoPlayer.c), and only exist when FFmpeg was
# built against the Rockchip MPP library. Asking FFmpeg itself is what decides it.
if(EXISTS ${BUILD_DIR}/lib/pkgconfig/libavcodec.pc)
file(READ ${BUILD_DIR}/lib/pkgconfig/libavcodec.pc avcodecPc)
if(avcodecPc MATCHES "rockchip_mpp")
target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE SINGE_RKMPP_DECODE)
message(STATUS "FFmpeg carries the rkmpp decoders; enabling them in the player")
endif()
endif()
# Jolt Physics comes with the compile definitions it was built with (SIMD level, layer bits) through
# its exported target; the one C++ file in the tree (physicsJolt.cpp) needs them. Its C++ runtime is
# zig's own libc++, linked statically, or the host's libstdc++ under gcc; macOS already lists -lc++.
find_package(Jolt CONFIG REQUIRED PATHS ${BUILD_DIR}/lib/cmake/Jolt NO_DEFAULT_PATH NO_CMAKE_FIND_ROOT_PATH)
# Recast Navigation the same way (navRecast.cpp): Recast bakes, Detour queries, DetourCrowd steers.
find_package(recastnavigation CONFIG REQUIRED PATHS ${BUILD_DIR}/lib/cmake/recastnavigation NO_DEFAULT_PATH NO_CMAKE_FIND_ROOT_PATH)
# RmlUi (gui.cpp, and guiRender.cpp, the SDL_GPU render interface forked from RmlUi's Backends).
# Its package config looks for Freetype::Freetype and Lua::Lua only when they are not already
# targets, so both are declared here from the superbuild's FreeType and the engine's own Lua.
add_library(Freetype::Freetype STATIC IMPORTED)
set_target_properties(Freetype::Freetype PROPERTIES IMPORTED_LOCATION ${BUILD_DIR}/lib/libfreetype.a INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_SOURCE_DIR}/thirdparty/SDL3_ttf/external/freetype/include)
add_library(Lua::Lua INTERFACE IMPORTED)
set_target_properties(Lua::Lua PROPERTIES INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_SOURCE_DIR}/thirdparty/lua/src)
find_package(RmlUi CONFIG REQUIRED PATHS ${BUILD_DIR}/lib/cmake/RmlUi NO_DEFAULT_PATH NO_CMAKE_FIND_ROOT_PATH)
set_target_properties(${CMAKE_PROJECT_NAME} PROPERTIES CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF)
# Jolt is built without RTTI, so the file that subclasses its debug renderer must be too, or the
# subclass's vtable wants type information the library never made.
set_source_files_properties(src/physicsJolt.cpp PROPERTIES COMPILE_OPTIONS "-fno-rtti")
if(NOT KANGAROO_OS STREQUAL "macos")
if(SINGE_ZIG_TARGET)
list(APPEND SYSTEM_LIBS -lc++)
else()
list(APPEND SYSTEM_LIBS -lstdc++)
endif()
endif()
# System libraries follow the static ones so their symbols resolve for everything above.
# Apple's linker has no --start-group, so the list is repeated there instead.
if(KANGAROO_OS STREQUAL "macos")
target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE ${STATIC_LIBS} ${STATIC_LIBS} ${STATIC_LIBS} Jolt::Jolt RecastNavigation::DetourCrowd RecastNavigation::Detour RecastNavigation::Recast RmlUi::Lua RmlUi::Core ${SYSTEM_LIBS} -pthread -lm)
else()
target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE -Wl,--start-group ${STATIC_LIBS} -Wl,--end-group Jolt::Jolt RecastNavigation::DetourCrowd RecastNavigation::Detour RecastNavigation::Recast RmlUi::Lua RmlUi::Core ${SYSTEM_LIBS} -pthread -lm)
endif()
install(TARGETS ${CMAKE_PROJECT_NAME})

View file

@ -1,119 +0,0 @@
{
"version": 6,
"cmakeMinimumRequired": {
"major": 3,
"minor": 25,
"patch": 0
},
"configurePresets": [
{
"name": "base",
"hidden": true,
"generator": "Unix Makefiles",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"SINGE_SUPERBUILD": "ON"
}
},
{
"name": "linux-x86_64",
"displayName": "Linux x86_64 (zig, glibc 2.28, release)",
"inherits": "base",
"binaryDir": "${sourceDir}/.builddir/linux/x86_64/superbuild",
"toolchainFile": "${sourceDir}/cmake/zig/x86_64-linux-gnu.cmake",
"cacheVariables": {
"SINGE_TRIPLE": "x86_64-linux-gnu",
"SINGE_CROSS_OS": "linux",
"SINGE_SUFFIX": "",
"SINGE_TREE": "linux"
}
},
{
"name": "linux-x86_64-gcc",
"displayName": "Linux x86_64 (host gcc, developer build)",
"inherits": "base",
"binaryDir": "${sourceDir}/.builddir/linux-gcc/x86_64/superbuild",
"toolchainFile": "${sourceDir}/cmake/linux-x86_64.cmake",
"cacheVariables": {
"SINGE_TRIPLE": "x86_64-linux-gnu",
"SINGE_CROSS_OS": "linux",
"SINGE_SUFFIX": "-gcc",
"SINGE_TREE": "linux-gcc"
}
},
{
"name": "windows-x86_64",
"displayName": "Windows x86_64 (zig)",
"inherits": "base",
"binaryDir": "${sourceDir}/.builddir/windows/x86_64/superbuild",
"toolchainFile": "${sourceDir}/cmake/zig/x86_64-windows-gnu.cmake",
"cacheVariables": {
"SINGE_TRIPLE": "x86_64-w64-mingw32",
"SINGE_CROSS_OS": "mingw32",
"SINGE_SUFFIX": ".exe"
}
},
{
"name": "linux-aarch64",
"displayName": "ARM64 Linux (zig)",
"inherits": "base",
"binaryDir": "${sourceDir}/.builddir/linux/aarch64/superbuild",
"toolchainFile": "${sourceDir}/cmake/zig/aarch64-linux-gnu.cmake",
"cacheVariables": {
"SINGE_TRIPLE": "aarch64-linux-gnu",
"SINGE_CROSS_OS": "linux",
"SINGE_SUFFIX": ""
}
},
{
"name": "macos-aarch64",
"displayName": "macOS Apple silicon (zig)",
"inherits": "base",
"binaryDir": "${sourceDir}/.builddir/macos/aarch64/superbuild",
"toolchainFile": "${sourceDir}/cmake/zig/aarch64-macos.cmake",
"cacheVariables": {
"SINGE_TRIPLE": "aarch64-apple-darwin22.4",
"SINGE_CROSS_OS": "darwin",
"SINGE_SUFFIX": ""
}
},
{
"name": "macos-x86_64",
"displayName": "macOS Intel (zig)",
"inherits": "base",
"binaryDir": "${sourceDir}/.builddir/macos/x86_64/superbuild",
"toolchainFile": "${sourceDir}/cmake/zig/x86_64-macos.cmake",
"cacheVariables": {
"SINGE_TRIPLE": "x86_64-apple-darwin22.4",
"SINGE_CROSS_OS": "darwin",
"SINGE_SUFFIX": ""
}
}
],
"buildPresets": [
{
"name": "linux-x86_64",
"configurePreset": "linux-x86_64"
},
{
"name": "linux-x86_64-gcc",
"configurePreset": "linux-x86_64-gcc"
},
{
"name": "windows-x86_64",
"configurePreset": "windows-x86_64"
},
{
"name": "linux-aarch64",
"configurePreset": "linux-aarch64"
},
{
"name": "macos-aarch64",
"configurePreset": "macos-aarch64"
},
{
"name": "macos-x86_64",
"configurePreset": "macos-x86_64"
}
]
}

674
COPYING
View file

@ -1,674 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program 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, either version 3 of the License, or
(at your option) any later version.
This program 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. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

145
INSTALL
View file

@ -1,145 +0,0 @@
SINGE 3.00
==========
(For the latest version of this document, visit https://kangaroopunch.com!
The full manual is extracted to Singe/Manual.pdf on first run, beside
Singe/COPYING and Singe/LICENSES.)
Welcome to Singe! SINGE Is Not a Game Emulator!
INSTALLATION & UPGRADE
======================
For the initial installation, create an empty directory and place the Singe
binary inside it. Run the binary with no command line options to cause it
to generate all the files needed to make Singe work properly. For example:
C:\Singe2\
C:\Singe2\Singe.exe
Once you run Singe.exe you'll see additional files and directories:
C:\Singe2\Singe\ (Several files created in this folder.)
C:\Singe2\Menu.bat (Or .sh on UNIX-ish OSs.)
To install games, simply unpack them and place their directory inside the
directory you created. For example, ActionMax.7z contains a folder named
"ActionMax". Place it here:
C:\Singe2\ActionMax\
For all Singe 2 and later games, it will automatically appear in the menu.
To upgrade, back up any changes you may have made to files inside the Singe
subdirectory that was generated during installation. (You really shouldn't
be changing things in there!) Delete the Singe subdirectory, Menu.bat (or
.sh) and run the new Singe binary with no command line arguments to generate
the new files.
COMMAND LINE
============
Run the binary with -h (or --help) for the option summary. Every option is
described in the "Command Line Options" chapter of Singe/Manual.pdf, which
is extracted on first run.
BUILDING FROM SOURCE
====================
On a Debian based system a checkout builds with one command:
./build-all.sh linux x86_64
It installs the host packages it needs with apt (asks for your sudo
password once), then runs the CMake superbuild, which builds every vendored
library (zlib, zstd, SDL3 and its satellites, OpenSSL, FFmpeg) into
.builddir/<os>/<arch> and then Singe against them. The finished binary is
copied to .builddir/Singe-v<version>-<Os>-<arch>.
Host packages (what build-all.sh installs): build-essential cmake
git-lfs pkg-config perl nasm llvm autoconf automake libtool meson
ninja-build imagemagick ffmpeg lua5.4 asciidoctor ruby-asciidoctor-pdf
libva-dev libvdpau-dev libdrm-dev libgl-dev libegl-dev libgles-dev
libgbm-dev libasound2-dev libpulse-dev libpipewire-0.3-dev
libjack-jackd2-dev libsndio-dev libudev-dev libdbus-1-dev
libibus-1.0-dev libxkbcommon-dev libx11-dev libxext-dev libxfixes-dev
libxi-dev libxcursor-dev libxrandr-dev libxss-dev libxtst-dev
libwayland-dev wayland-protocols libdecor-0-dev.
The artwork, the font and the menu video in assets/ are stored with Git
LFS. Clone with git-lfs present, or those files arrive as small text
pointers and the build embeds the pointers instead of the assets; a
checkout already made without it is repaired with "git lfs install &&
git lfs pull". build-all.sh stops with an error rather than build a
binary around them.
The Linux release build and the Windows build use zig as the compiler:
the build fetches a pinned zig release (version and SHA-256 in
cmake/zig/zig.cmake) into .builddir/toolchains on first use. For Linux it
targets glibc 2.28, so the binary runs on distributions back to Debian 10
and Ubuntu 18.04 while linking the host's platform libraries (X11, ALSA,
VA-API, VDPAU, DRM); for Windows zig carries the headers and import
libraries itself, so nothing else is installed:
./build-all.sh windows x86_64
A developer build with the host's own gcc, glibc and symbols is the
linux-x86_64-gcc preset (binary Singe-v3.00-Linux-x86_64-gcc).
macOS (Apple silicon, macOS 13 or newer) builds with zig too, but needs
an Apple SDK for the frameworks.
Apple only ships it inside Xcode, so by default the build fetches the
MacOSX15.5 SDK tarball from the community mirror osxcross users share
(github.com/joseluisq/macosx-sdks), checks its SHA-256, and unpacks it to
.builddir/toolchains/MacOSX.sdk on first use. To avoid the download,
put a tarball holding a single MacOSX*.sdk directory at
.builddir/toolchains/MacOSX.sdk.tar.xz
(on a Mac with Xcode, osxcross's tools/gen_sdk_package.sh makes exactly
that), or point SINGE_MACOS_SDK at an unpacked SDK. SINGE_MACOS_SDK_URL
and SINGE_MACOS_SDK_SHA256 pick a different mirror. Then:
./build-all.sh macos universal
builds the Apple silicon and Intel presets (macos-aarch64, macos-x86_64)
and joins them with llvm-lipo into Singe-v3.00-Macos-universal. Either
architecture alone is ./build-all.sh macos aarch64 or x86_64.
The 3D scene's shaders are HLSL, compiled when Singe is built. The
superbuild builds SDL_shadercross and SPIRV-Cross for the host machine
once (into .builddir/toolchains/host, shared by every preset) and fetches
Microsoft's prebuilt DirectX Shader Compiler, which SDL_shadercross
drives, into .builddir/toolchains/dxc. That prebuilt package exists for
x86_64 Linux hosts; on another host, unpack a DirectX Shader Compiler
release (lib/libdxcompiler.so, lib/libdxil.so, include/dxc/dxcapi.h) at
that path yourself, or build it from source there.
The ARM64 Linux build targets any ARM64 Linux system with glibc 2.31 or
newer, the 64-bit Raspberry Pi OS among them. It is not Pi specific: the
decoder it builds talks to any V4L2 memory-to-memory device, so one binary
also serves Amlogic, Exynos, Qualcomm and other boards whose kernel offers
one, and it carries Rockchip's own decoders besides. 3D games need a Pi 4
or later, which has OpenGL ES 3.1 (and Vulkan); the Pi 3 has only OpenGL
ES 2.0 and plays 2D games only. The build
uses zig. The platform headers and libraries it links against are Debian
bookworm arm64 packages listed in cmake/zig/arm64Packages.cmake, fetched
from snapshot.debian.org and unpacked with dpkg-deb into
.builddir/toolchains/sysroot-aarch64-linux-gnu on first use:
./build-all.sh linux aarch64
Useful forms (anything after the platform goes to cmake --build):
./build-all.sh linux x86_64 --target singe Singe alone
./build-all.sh linux x86_64 --target rebuild-ffmpeg redo one library
Without the wrapper:
cmake --preset linux-x86_64 && cmake --build --preset linux-x86_64
Presets: linux-x86_64, linux-x86_64-gcc, windows-x86_64, linux-aarch64,
macos-aarch64 (CMakePresets.json).

View file

@ -1,75 +0,0 @@
Singe was made possible by a number of third-party software libraries
generously provided as open source by their respective authors. Without
these tools, Singe would not exist.
The version column is the release vendored under thirdparty/ (or, for the
libraries bundled inside the SDL trees and the host tools, the copy those
trees carry). Projects without releases are listed by snapshot date.
arg_parser 1.21 BSD-2-Clause http://savannah.nongnu.org/projects/arg-parser
basis_universal 2.50 Apache-2.0 https://github.com/BinomialLLC/basis_universal
binaryheap.lua 0.4 MIT http://tieske.github.io/binaryheap.lua
brotli 1.2.0 MIT https://github.com/google/brotli
bump.lua 3.1.7 MIT https://github.com/kikito/bump.lua
cgltf 1.15 MIT https://github.com/jkuhlmann/cgltf
copas 4.12.0 MIT https://lunarmodules.github.io/copas
dav1d 1.5.4 BSD-2-Clause https://code.videolan.org/videolan/dav1d
DirectXShaderCompiler 1.10.2605.37 NCSA https://github.com/microsoft/DirectXShaderCompiler (prebuilt, downloaded at build time; host tool only)
ffmpeg 9.0.1 LGPL-2.1 https://ffmpeg.org
freetype 2.13.2 FTL https://freetype.org (bundled with SDL3_ttf)
game-music-emu 0.6.6 LGPL-2.1 https://github.com/libgme/game-music-emu (bundled with SDL3_mixer)
harfbuzz 14.4.0 MIT https://harfbuzz.github.io (bundled with SDL3_ttf)
highway 1.4.0 Apache-2.0 https://github.com/google/highway (bundled with libjxl)
inspect.lua 3.1.0 MIT https://github.com/kikito/inspect.lua
JoltPhysics 5.6.0 MIT https://github.com/jrouwe/JoltPhysics
json.lua 0.1.2 MIT https://github.com/rxi/json.lua
libjpeg 9f IJG https://ijg.org (bundled with SDL3_image)
libjxl 0.7.3-SDL BSD-3-Clause https://github.com/libsdl-org/libjxl (bundled with SDL3_image)
libogg 1.3.5 BSD-3-Clause https://xiph.org/ogg (bundled with SDL3_mixer)
libpng 1.6.58 libpng-2.0 http://www.libpng.org (bundled with SDL3_image)
librs232 1.0.4 MIT https://github.com/srdgame/librs232
libwebp 1.3.2 BSD-3-Clause https://developers.google.com/speed/webp (bundled with SDL3_image)
libxmp 4.7.0 MIT https://github.com/libxmp/libxmp (bundled with SDL3_mixer)
lsqlite3 0.9.7 MIT http://lua.sqlite.org
lua 5.4.9 MIT https://www.lua.org
luafilesystem 1.9.0 MIT https://lunarmodules.github.io/luafilesystem
luasec 1.3.2 MIT https://github.com/lunarmodules/luasec
luasocket 3.1.0 MIT https://lunarmodules.github.io/luasocket
lume 2.3.0 MIT https://github.com/rxi/lume
manymouse 0.0.3 Zlib https://icculus.org/manymouse
middleclass 4.1.1 MIT https://github.com/kikito/middleclass
openssl 3.5.8 Apache-2.0 https://www.openssl.org
opus 1.4 BSD-3-Clause https://opus-codec.org (bundled with SDL3_mixer)
opusfile 0.12 BSD-3-Clause https://opus-codec.org (bundled with SDL3_mixer)
plutosvg 0.0.8 MIT https://github.com/sammycage/plutosvg (bundled with SDL3_ttf)
plutovg 1.3.3 MIT https://github.com/sammycage/plutovg (bundled with SDL3_ttf)
recastnavigation 1.6.0 Zlib https://github.com/recastnavigation/recastnavigation
RmlUi 6.3 MIT https://github.com/mikke89/RmlUi
SDL3 3.4.16 Zlib https://www.libsdl.org
SDL3_image 3.4.6 Zlib https://www.libsdl.org
SDL3_mixer 3.2.4 Zlib https://www.libsdl.org
SDL3_ttf 3.2.2 Zlib https://www.libsdl.org
SDL_shadercross main 2026-09 Zlib https://github.com/libsdl-org/SDL_shadercross (host tool only)
skcms 0.7.3-SDL BSD-3-Clause https://skia.googlesource.com/skcms (bundled with libjxl)
SPIRV-Cross main 2026-09 Apache-2.0 https://github.com/KhronosGroup/SPIRV-Cross (host tool only)
sqlite 3.53.4 Public-Domain https://sqlite.org
timerwheel.lua 1.0.2 MIT https://tieske.github.io/timerwheel.lua
TinySoundFont 2026-07-19 MIT https://github.com/schellingb/TinySoundFont
uthash 2.4.0 BSD-1-Clause https://troydhanson.github.io/uthash
vlc 3.0 headers LGPL-2.1 https://www.videolan.org/vlc (two language-code headers only)
wavpack 5.9.0 BSD-3-Clause https://www.wavpack.com (bundled with SDL3_mixer)
zlib 1.3.2 Zlib https://zlib.net
zstd 1.5.7 BSD-3-Clause https://facebook.github.io/zstd
Fonts
-----
FreeSansBold GPL-3.0 with font exception https://www.gnu.org/software/freefont
Data
----
Mozilla CA list 20260601 (Debian ca-certificates) MPL-2.0 https://wiki.mozilla.org/CA (assets/cacerts.pem)

View file

@ -1,626 +0,0 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program 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; either version 3
* of the License, or (at your option) any later version.
*
* This program 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. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- The menu's backdrop: the intro and the grid the menu sits on, as one scene.
--
-- A charge goes off, the Singe dragon and name fly out of it toward the camera, the tagline fades
-- in underneath, the dragon rears and breathes flame -- and then the grid comes up out of the dark
-- beneath the logo while the smoke is still clearing, the logo lifts away, and what is left is the
-- backdrop the menu sits on. One scene from the first frame to the last: no cut, and nothing
-- loads at the join.
--
-- It lives here rather than inside Singe/MenuDocument.singe because two things draw it: the menu,
-- and util/renderMenuVideo.py, which records it so a machine with no GPU can play what a machine
-- with one draws. A second copy would be a second thing to keep in step, and the recording would
-- quietly stop matching the menu.
--
-- Needs the GPU device the 3D scene uses; Singe/MenuOverlay.singe is the path for a machine
-- without one.
-- The backdrop: the intro and the grid the menu sits on, as one scene. Every number here came
-- from testScripts/menuIntro.singe, which is where the pacing was worked out; changing one is
-- best done there first, where a contact sheet of the whole eight seconds is one command away.
local BLAST_Z = -30.0 -- Where the charge goes off.
local BLAST_Y = 2.5 -- and how high, which is where the logo comes from.
local REST_Z = -8.0 -- Where the logo ends up.
local CAM_Z = 2.0
local FLY_START = 0.30 -- Seconds: the logo leaves the blast.
local FLY_END = 1.90 -- and has settled.
local TAG_START = 2.10 -- The tagline begins to appear.
local TAG_END = 3.00
local SPINS = 2.0 -- Turns the logo makes on the way out.
local IDLE_TURN = 7.0 -- Degrees it drifts either side once it has settled.
local IDLE_RATE = 0.9 -- Radians a second of that drift.
local GRID_UP = 4.30 -- Seconds: the grid starts to come up out of the dark.
local GRID_LIT = 5.60 -- and is fully lit.
local LIFT_FROM = 5.90 -- The logo starts to leave.
local LIFT_TO = 7.30 -- and is gone; the menu has the screen from here.
local LOOP_AT = 9.90 -- Where the recording of all this begins its looping section, and
-- where Singe/menuIntro.flac, which Singe/Menu.singe plays over
-- either renderer, has finished fading out. The menu has had the
-- screen since LIFT_TO; these last seconds are the grid running on
-- under it while the music leaves, which is why this is not simply
-- LIFT_TO. It is a whole number of frames -- and of keyframes -- at
-- the step util/renderMenuVideo.py records at, which checks that
-- rather than taking it on trust.
local LIFT_RISE = 13.0 -- World units it climbs on the way out.
-- The backdrop, which is the grid the menu sits on. Same numbers as testScripts/gridBackground.
local CELL = 6.0
local LINE_W = 0.12
local CROSS_W = 0.18
local SPREAD = 16.0
local HALF_X = 12
local DEPTH = 14
-- The grid repeats itself every GRID_PERIOD, because it is slid by one cell and wrapped. The
-- period is the constant and the speed follows from it, rather than the other way round, so that
-- a recording of the loop can be a whole number of frames: at 6 units and 9 a second the period
-- was 2/3 of a second, which is not a whole number of frames at any integer step, and the recorded
-- loop could never quite meet itself.
local GRID_PERIOD = 0.66
local GRID_SPEED = CELL / GRID_PERIOD
local FOG_R = 8
local FOG_G = 3
local FOG_B = 20
local FOG_NEAR = 12
local FOG_FAR = 72
local SUN_R = 10.5
local SUN_Y = 7.6
local SUN_Z = -34.0
local SUN_SLICES = 6
local SUN_SEGMENTS = 72
local HORIZ_W = 70.0
local HORIZ_H = 0.5
local HORIZ_Z = -38.0
local HORIZ_D = 2.2
local CAM_Y = 2.7 -- Eye height, shared by the logo and the grid: the backdrop's
-- perspective is tuned to it, and at the intro's old 1.2 the grid came
-- up flattened with its nearest line a slab across the bottom.
local LOGO_Y = 0.15 -- Where the logo settles. It is the dragon that is centred on the
-- sun, not the pair of them: this puts the dragon's middle on the
-- sun's middle, which leaves the name below it crossing the horizon
-- rather than sitting across the sun's face and hiding it. Measured
-- rather than judged by eye -- a still with the dragon hidden gives
-- the sun's disc, one with it shown gives the dragon's -- so it is
-- worth re-measuring if either model changes.
-- The dragon carries no animation, but util/dragonModel.py builds it out of named parts -- head,
-- neck, wingL, wingR and the rest -- and every one of them is its own node. They all sit at the
-- model's origin though, so turning a wing node would swing the wing round the dragon's middle
-- rather than its shoulder. The joints are baked into the model now; coordinates below are the
-- model's own: it faces +X, +Y is up, and the wings reach out along +/-Z.
local MOUTH = { 3.55, 5.60, 0.00 } -- Where the flame leaves, in model coordinates.
local HEAD_AT = { 0.62, 5.69, 0.00 } -- The head's baked joint; see assets/DragonPivots.json.
local FLAP_MAX = 34.0 -- Degrees a wing rises and falls.
local FLAP_FAST = 13.0 -- Radians a second while it is flying out of the blast.
local FLAP_SLOW = 3.4 -- and once it has settled.
local REAR_BACK = 3.05 -- Seconds: the head starts to rear.
local REAR_TOP = 3.40 -- fully back.
local REAR_STRIKE = 3.58 -- and thrown forward.
local BREATH_START = 3.46
local BREATH_END = 4.35
local REAR_DONE = 4.90 -- Back to where it started.
local REAR_ANGLE = 34.0 -- Degrees the head tips back.
local STRIKE_ANGLE = -14.0 -- and forward through the strike.
local DRAGON_YAW = -32.0 -- The dragon alone is turned toward the camera, so the wingbeat is
-- seen three quarters on rather than edge on; the name stays square
-- to the camera because it has to be read.
local TAGLINE = "SINGE Is Not a Game Emulator"
local backdropStarted = 0 -- singeGetTicks() when the backdrop's clock began.
local backdropTagX = 0
local backdropTagY = 0
local blown = false -- Whether the charge has gone off.
local gridLit = false -- Whether the grid has finished coming up and its colours are final.
local backdropFont = nil
-- ===== The backdrop ==========================================================================
--
-- The intro and the grid are one scene: the blast, the logo out of it, the tagline, and then the
-- grid rising out of the dark beneath the logo while the smoke is still clearing, the logo lifting
-- away, and the backdrop left running under the menu. There is no cut and nothing loads at the
-- join, which is the whole point of doing it this way rather than playing a video and switching.
-- The scene itself. Opaque black to begin with: the intro happens in the dark, and the disc
-- behind it is stopped rather than shown through, so this is the whole picture from the first
-- frame until the grid comes up and the background goes with it.
sceneEnable(true)
sceneSetBackground(0, 0, 0, 255)
sceneSetAmbient(14, 12, 16)
sceneSetBloom(1.2, 0.22)
sceneSetTonemap(TONEMAP_ACES)
-- The dragon's parts carry their own joints now: util/objToGlb.py bakes them from
-- assets/DragonPivots.json, so wingL turns at the shoulder and head at the top of the neck without
-- anything being rigged here. This used to build them at run time from bounding boxes, which
-- worked but left every future use of the model to re-derive the same thing.
local function joint(root, name, parent)
local part = nodeFind(name, root)
if (part ~= nil) and (parent ~= nil) then
local px, py, pz = nodeGetPosition(part)
local qx, qy, qz = nodeGetPosition(parent)
-- Both parts carry their own baked joint, measured from the model's origin. Hanging one
-- off the other makes the child's offset count from the parent's, so the two would add up
-- and the head would fly off above the neck -- which is exactly what it did.
nodeSetParent(part, parent)
nodeSetPosition(part, px - qx, py - qy, pz - qz)
end
return part
end
-- Eases in: slow to start, quick away. The logo leaves this way so the move begins as a drift.
local function easeIn(t)
return t * t * t
end
-- Eases out: fast off the mark, slow into the stop.
local function easeOut(t)
return 1 - (1 - t) * (1 - t) * (1 - t)
end
-- How far through a span the time is, 0 before it and 1 after.
local function span(t, from, to)
return math.max(math.min((t - from) / (to - from), 1), 0)
end
local blast = nodeNew()
nodeSetPosition(blast, 0, BLAST_Y, BLAST_Z)
local smoke = emitterNew(blast)
emitterSetMax(smoke, 400)
emitterSetBlend(smoke, PARTICLE_ALPHA)
emitterSetRadius(smoke, 1.6)
emitterSetSpread(smoke, 360)
emitterSetSpeed(smoke, 2.5, 9.0)
emitterSetDrag(smoke, 1.3)
emitterSetGravity(smoke, 0, 3.2, 0)
emitterSetLife(smoke, 1.2, 2.4)
emitterSetSize(smoke, 3.2, 12.0)
emitterSetSpin(smoke, -40, 40)
emitterSetColor(smoke, 64, 46, 38, 170, 10, 9, 12, 0)
local core = emitterNew(blast)
emitterSetMax(core, 500)
emitterSetBlend(core, PARTICLE_ADD)
emitterSetRadius(core, 1.1)
emitterSetSpread(core, 360)
emitterSetSpeed(core, 5.0, 20.0)
emitterSetDrag(core, 1.7)
emitterSetGravity(core, 0, 5.0, 0)
emitterSetLife(core, 0.6, 1.3)
emitterSetSize(core, 2.0, 8.2)
emitterSetColor(core, 255, 232, 150, 255, 190, 35, 0, 0)
local flash = emitterNew(blast)
emitterSetMax(flash, 20)
emitterSetBlend(flash, PARTICLE_ADD)
emitterSetRadius(flash, 0.4)
emitterSetSpread(flash, 360)
emitterSetSpeed(flash, 0, 1.5)
emitterSetLife(flash, 0.10, 0.22)
emitterSetSize(flash, 6.0, 15.0)
emitterSetColor(flash, 255, 255, 235, 255, 255, 150, 40, 0)
local sparks = emitterNew(blast)
emitterSetMax(sparks, 300)
emitterSetBlend(sparks, PARTICLE_ADD)
emitterSetRadius(sparks, 0.5)
emitterSetSpread(sparks, 360)
emitterSetSpeed(sparks, 12.0, 34.0)
emitterSetDrag(sparks, 0.25)
emitterSetGravity(sparks, 0, -15.0, 0)
emitterSetLife(sparks, 0.5, 1.5)
emitterSetSize(sparks, 0.30, 0.05)
emitterSetSpin(sparks, -200, 200)
emitterSetTrail(sparks, 8, 0.10)
emitterSetColor(sparks, 255, 226, 160, 255, 255, 80, 15, 0)
-- The blast's own light, so the logo is lit by the fire it comes out of.
local fireLight = lightNew(LIGHT_POINT)
nodeSetParent(fireLight, blast)
lightSetColor(fireLight, 255, 150, 60)
lightSetRange(fireLight, 60)
lightSetIntensity(fireLight, 0)
-- A key light for the logo once the fire has died, from over the camera's shoulder.
local key = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(key, 4, 6, 10)
nodeLookAt(key, 0, 0, REST_Z)
lightSetIntensity(key, 1.15)
-- A magenta rim from behind and to the left, the colour the grid behind the menu is drawn in, so
-- the logo is not a flat white cut-out and the intro and the menu share a palette.
local rim = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(rim, -7, 3, REST_Z - 9)
nodeLookAt(rim, 0, 1.5, REST_Z)
lightSetColor(rim, 255, 70, 200)
lightSetIntensity(rim, 2.6)
-- The dragon over the name, assembled the way testScripts/scene30.singe does it.
local logo = nodeNew()
local text = modelInstance(modelLoad("Singe/SingeText.glb"))
nodeSetParent(text, logo)
local dragon = modelInstance(modelLoad("Singe/DragonModel.glb"))
nodeSetParent(dragon, logo)
nodeSetPosition(dragon, 0, 4.85, 0)
nodeSetRotation(dragon, 0, DRAGON_YAW, 0)
nodeSetScale(dragon, 0.9)
nodeSetScale(logo, 0.5)
nodeSetVisible(logo, false)
-- The rig. The head hangs off the neck so that rearing the neck carries the head with it.
local wingLeft = joint(dragon, "wingL")
local wingRite = joint(dragon, "wingR")
local neck = joint(dragon, "neck")
-- The head hangs off the neck so that rearing the neck carries the head with it.
local head = joint(dragon, "head", neck)
-- The flame, on a node at the dragon's mouth so it follows the head round.
local jet = nodeNew()
nodeSetParent(jet, head)
-- Relative to the head's own joint, which is where its node now sits.
nodeSetPosition(jet, MOUTH[1] - HEAD_AT[1], MOUTH[2] - HEAD_AT[2], MOUTH[3] - HEAD_AT[3])
-- The flame's own light, so the dragon is lit by what it is breathing.
local breathLight = lightNew(LIGHT_POINT)
nodeSetParent(breathLight, jet)
lightSetColor(breathLight, 255, 160, 60)
lightSetRange(breathLight, 14)
lightSetIntensity(breathLight, 0)
local breath = emitterNew(jet)
emitterSetMax(breath, 400)
emitterSetBlend(breath, PARTICLE_ADD)
emitterSetRadius(breath, 0.05)
emitterSetSpread(breath, 13)
emitterSetSpeed(breath, 4.5, 9.5)
emitterSetDrag(breath, 3.0)
emitterSetLife(breath, 0.28, 0.55)
emitterSetSize(breath, 0.10, 1.30)
emitterSetRate(breath, 260)
emitterSetColor(breath, 255, 244, 200, 255, 235, 60, 0, 0)
-- The tagline is measured in backdropBegin instead, once a font has been selected: at this point
-- the document renderer has not chosen one, and fontToSprite ends the game without it.
-- ===== The backdrop ==========================================================================
--
-- Built now and left unlit. Bringing it up is a ramp on the emissive colours rather than anything
-- appearing, so the grid rises out of the dark the way a light comes on instead of cutting in.
local function quad(p, i, x0, z0, x1, z1, x2, z2, x3, z3)
local base = #p / 3
for _, c in ipairs({ { x0, z0 }, { x1, z1 }, { x2, z2 }, { x3, z3 } }) do
p[#p + 1] = c[1]
p[#p + 1] = 0
p[#p + 1] = c[2]
end
for _, n in ipairs({ 1, 2, 3, 1, 3, 4 }) do
i[#i + 1] = base + n
end
end
-- A line of constant width thins to nothing as it recedes and breaks into crawling dots; widening
-- it with distance holds it at about the same width on screen the whole way to the horizon.
local function spread(z)
return 1 + (-z) / SPREAD
end
-- The height at z that lies on the horizon line, so the bar and the sun's cut land on the same row.
local function horizonY(z)
return CAM_Y - (CAM_Z - z) * math.tan(math.rad(HORIZ_D))
end
local function gridMesh()
local p = {}
local i = {}
local far = -DEPTH * CELL
local wide = HALF_X * CELL
local hN = LINE_W / 2
local hF = hN * spread(far)
for n = -HALF_X, HALF_X do
local x = n * CELL
quad(p, i, x - hN, CELL, x + hN, CELL, x + hF, far, x - hF, far)
end
for n = -1, DEPTH do
local z = -n * CELL
local h = CROSS_W / 2 * spread(z)
quad(p, i, -wide, z + h, wide, z + h, wide, z - h, -wide, z - h)
end
return meshNew(p, nil, nil, i)
end
-- The sun: the part of a disc above yClip, as a fan from the middle of the chord.
local function sunMesh(yClip)
local p = { 0, yClip, 0 }
local i = {}
local a0 = math.asin(math.max(math.min(yClip / SUN_R, 1), -1))
for n = 0, SUN_SEGMENTS do
local a = a0 + (n / SUN_SEGMENTS) * (math.pi - a0 * 2)
p[#p + 1] = math.cos(a) * SUN_R
p[#p + 1] = math.sin(a) * SUN_R
p[#p + 1] = 0
end
for n = 1, SUN_SEGMENTS do
i[#i + 1] = 1
i[#i + 1] = n + 1
i[#i + 1] = n + 2
end
return meshNew(p, nil, nil, i)
end
-- Unlit, and brought up by their base colour rather than by emissive. The intro has a key light
-- and a rim for the logo, and a lit material with a black base still catches their specular: the
-- grid and the sun showed as a grey sheen from the first frame, lit before they were meant to
-- exist. Unlit takes the lights out of it entirely and the ramp below is the only thing that
-- decides when the backdrop appears.
local gridMat = materialNew()
materialSetColor(gridMat, 0, 0, 0)
materialSetUnlit(gridMat, true)
materialSetDoubleSided(gridMat, true)
local sunMat2 = materialNew()
materialSetColor(sunMat2, 0, 0, 0)
materialSetUnlit(sunMat2, true)
materialSetDoubleSided(sunMat2, true)
local horizMat = materialNew()
materialSetColor(horizMat, 0, 0, 0)
materialSetUnlit(horizMat, true)
materialSetDoubleSided(horizMat, true)
local sliceMat = materialNew()
materialSetColor(sliceMat, FOG_R, FOG_G, FOG_B)
materialSetUnlit(sliceMat, true)
materialSetDoubleSided(sliceMat, true)
local backdrop = nodeNew()
local grid = nodeNew()
nodeSetMesh(grid, gridMesh(), gridMat)
nodeSetParent(grid, backdrop)
local horizon = nodeNew()
nodeSetMesh(horizon, meshPlane(HORIZ_W * 2, HORIZ_H), horizMat)
nodeSetRotation(horizon, 90, 0, 0)
nodeSetPosition(horizon, 0, horizonY(HORIZ_Z), HORIZ_Z)
nodeSetParent(horizon, backdrop)
local sunCut = horizonY(SUN_Z) - SUN_Y
local backSun = nodeNew()
nodeSetMesh(backSun, sunMesh(sunCut), sunMat2)
nodeSetPosition(backSun, 0, SUN_Y, SUN_Z)
nodeSetParent(backSun, backdrop)
for n = 1, SUN_SLICES do
local slice = nodeNew()
local height = 0.62 - (n - 1) * 0.07
local y = sunCut + 0.5 + (n - 1) * 1.3
local w = math.sqrt(math.max(SUN_R * SUN_R - (math.abs(y) + height / 2) ^ 2, 0))
nodeSetMesh(slice, meshPlane(w * 2, height), sliceMat)
nodeSetRotation(slice, 90, 0, 0)
nodeSetPosition(slice, 0, SUN_Y + y, SUN_Z + 0.4)
nodeSetParent(slice, backdrop)
end
local camera = nodeNew()
nodeSetPosition(camera, 0, CAM_Y, CAM_Z)
cameraSet(camera)
-- The backdrop is the disc for the intro and the 3D scene after it. The grid section of
-- Singe/menuBackground.mkv is never reached: this renderer has the GPU device the document needs,
-- which is the same one the scene draws with, so the grid is drawn rather than played.
-- One frame of the backdrop's timeline.
function backdropFrame()
local t = (singeGetTicks() - backdropStarted) / 1000.0
local fly = span(t, FLY_START, FLY_END)
local tag = span(t, TAG_START, TAG_END)
if not blown then
blown = true
emitterBurst(smoke, 85)
emitterBurst(core, 110)
emitterBurst(flash, 5)
emitterBurst(sparks, 130)
end
-- The fire's light dies over the first second and a half.
lightSetIntensity(fireLight, 400 * (1 - span(t, 0.05, 1.5)))
-- The grid comes up out of the dark while the smoke is still clearing, so the two overlap and
-- there is no moment where one thing has ended and the next has not begun.
local lit = span(t, GRID_UP, GRID_LIT)
if (lit > 0) and not gridLit then
materialSetColor(gridMat, 255 * lit, 45 * lit, 190 * lit)
materialSetColor(horizMat, 255 * lit, 150 * lit, 235 * lit)
materialSetColor(sunMat2, 255 * lit, 130 * lit, 55 * lit)
-- The sky stops being black at the same time, or the grid would rise into a void.
sceneSetBackground(FOG_R * lit, FOG_G * lit, FOG_B * lit, 255)
sceneSetFog(FOG_R, FOG_G, FOG_B, FOG_NEAR, FOG_FAR)
-- Once fully lit the colours are final, so they are not written again every frame.
gridLit = (lit >= 1)
end
if lit > 0 then
-- Scrolling from the moment it is visible, so it is already alive when it arrives.
nodeSetPosition(grid, 0, 0, ((t - GRID_UP) * GRID_SPEED) % CELL)
end
if fly > 0 then
local e = easeOut(fly)
-- Once it has arrived it keeps turning slowly, so the held frame is not a still picture.
local idle = IDLE_TURN * math.sin((t - FLY_END) * IDLE_RATE) * fly
local yaw = 360 * SPINS * (1 - e) + idle
-- Out of the blast, then up and away once the grid has taken over. Rising rather than
-- fading: a model has no alpha to fade without touching every material it came with.
local leave = easeIn(span(t, LIFT_FROM, LIFT_TO))
nodeSetVisible(logo, leave < 1)
nodeSetPosition(logo, 0, LOGO_Y * e + LIFT_RISE * leave, BLAST_Z + (REST_Z - BLAST_Z) * e)
nodeSetRotation(logo, 0, yaw, 0)
-- Wings: beating hard on the way out, easing to a hover once it has arrived.
local rate = FLAP_FAST + (FLAP_SLOW - FLAP_FAST) * e
local flap = FLAP_MAX * math.sin(t * rate)
nodeSetRotation(wingLeft, -flap, 0, 0)
nodeSetRotation(wingRite, flap, 0, 0)
-- The head rears back, snaps forward, and settles; the flame goes with the strike.
local pitch = 0
if t < REAR_TOP then
pitch = REAR_ANGLE * span(t, REAR_BACK, REAR_TOP)
elseif t < REAR_STRIKE then
local k = span(t, REAR_TOP, REAR_STRIKE)
pitch = REAR_ANGLE + (STRIKE_ANGLE - REAR_ANGLE) * k
else
pitch = STRIKE_ANGLE * (1 - easeOut(span(t, REAR_STRIKE, REAR_DONE)))
end
nodeSetRotation(head, 0, 0, pitch)
nodeSetRotation(neck, 0, 0, pitch * 0.35)
-- The flame leaves the mouth along the dragon's own forward, which the whole logo has been
-- turning; a 3D emitter takes its direction in world axes, so it is turned here by hand.
local aim = math.rad(pitch)
local spin = math.rad(yaw + DRAGON_YAW)
emitterSetDirection(breath, math.cos(aim) * math.cos(spin), math.sin(aim), -math.cos(aim) * math.sin(spin))
if t >= BREATH_START and t < BREATH_END then
emitterStart(breath)
lightSetIntensity(breathLight, 26)
else
emitterStop(breath)
lightSetIntensity(breathLight, 0)
end
end
-- The tagline goes with the logo. It is drawn into the overlay, which the menu clears every
-- frame, so it has to be redrawn here rather than left standing.
local gone = span(t, LIFT_FROM, LIFT_TO - 0.4)
if (tag > 0) and (gone < 1) then
colorForeground(235, 225, 240, math.floor(255 * tag * (1 - gone)))
fontPrint(backdropTagX, backdropTagY, TAGLINE)
end
end
-- Starts the backdrop's clock. showIntro false starts it past the end of the intro, so returning
-- from a game lands on the settled grid and the same timeline runs on from there.
function backdropBegin(showIntro)
-- A font of its own. The document renderer draws everything else through RmlUi and never
-- selects one, so the tagline -- the only thing put straight into the overlay -- brings it.
backdropFont = fontLoad("Singe/FreeSansBold.ttf", 30)
fontSelect(backdropFont)
fontQuality(FONT_QUALITY_BLENDED)
-- fontPrint has no centring, so the tagline is measured once through a throwaway sprite; it
-- is drawn rather than kept as a sprite because a sprite cannot be tinted and the fade needs
-- the foreground colour's alpha.
local measure = fontToSprite(TAGLINE)
backdropTagX = math.floor((overlayGetWidth() - spriteGetWidth(measure)) / 2)
backdropTagY = math.floor(overlayGetHeight() * 0.80)
spriteUnload(measure)
backdropStarted = singeGetTicks() - (showIntro and 0 or (LIFT_TO * 1000))
blown = not showIntro
gridLit = false
if not showIntro then
nodeSetVisible(logo, false)
end
end
-- Seconds since the backdrop began.
function backdropTime()
return (singeGetTicks() - backdropStarted) / 1000.0
end
-- Whether the intro is over and the screen belongs to whatever sits on the backdrop.
function backdropReady()
return backdropTime() >= LIFT_TO
end
-- How long the whole thing lasts, and how long one turn of the grid takes, so a recording knows
-- where the intro ends and how much of the loop to keep.
function backdropIntroSeconds()
return LIFT_TO
end
function backdropLoopSeconds()
return GRID_PERIOD
end
-- Where the intro ends and the loop begins, which is where the sting hands over to the bed.
function backdropLoopAt()
return LOOP_AT
end
function backdropFinish()
if backdropFont then
fontUnload(backdropFont)
backdropFont = nil
end
end

BIN
assets/Dragon.jpeg (Stored with Git LFS)

Binary file not shown.

View file

@ -1,34 +0,0 @@
# Materials for Dragon.obj
newmtl dark
Ka 0.060 0.062 0.066
Kd 0.300 0.310 0.330
Ks 0.700 0.700 0.700
Ns 180.0
d 1.00
Ni 1.00
illum 2
Pm 0.80
Pr 0.35
newmtl champagne
Ka 0.144 0.128 0.108
Kd 0.720 0.640 0.540
Ks 0.850 0.800 0.700
Ns 120.0
d 1.00
Ni 1.00
illum 2
Pm 0.80
Pr 0.40
newmtl glass
Ka 0.176 0.184 0.180
Kd 0.880 0.920 0.900
Ks 0.950 0.950 0.950
Ns 300.0
d 0.55
Ni 1.50
illum 4
Pm 0.00
Pr 0.15

File diff suppressed because it is too large Load diff

View file

@ -1,79 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="193 22 889 749" width="889" height="749">
<!-- Singe dragon, reconstructed as polygons from Dragon.jpeg; transparent background. -->
<defs>
<linearGradient id="g0" gradientUnits="userSpaceOnUse" x1="698.7" y1="96.1" x2="629.6" y2="137.3"><stop offset="0" stop-color="#6f7277"/><stop offset="1" stop-color="#97969a"/></linearGradient>
<linearGradient id="g1" gradientUnits="userSpaceOnUse" x1="503.6" y1="307.1" x2="382.6" y2="159.3"><stop offset="0" stop-color="#66686f"/><stop offset="1" stop-color="#8a8b90"/></linearGradient>
<linearGradient id="g2" gradientUnits="userSpaceOnUse" x1="845.5" y1="159.5" x2="866.9" y2="156.5"><stop offset="0" stop-color="#73777b"/><stop offset="1" stop-color="#73767c"/></linearGradient>
<linearGradient id="g3" gradientUnits="userSpaceOnUse" x1="835.5" y1="229.1" x2="842.3" y2="253.1"><stop offset="0" stop-color="#54575d"/><stop offset="1" stop-color="#5a5d63"/></linearGradient>
<linearGradient id="g4" gradientUnits="userSpaceOnUse" x1="738.8" y1="402.4" x2="778.4" y2="373.8"><stop offset="0" stop-color="#4e5357"/><stop offset="1" stop-color="#55595e"/></linearGradient>
<linearGradient id="g5" gradientUnits="userSpaceOnUse" x1="502.9" y1="620.5" x2="456.0" y2="520.4"><stop offset="0" stop-color="#52575e"/><stop offset="1" stop-color="#5a6166"/></linearGradient>
<linearGradient id="g6" gradientUnits="userSpaceOnUse" x1="854.9" y1="616.3" x2="901.1" y2="622.7"><stop offset="0" stop-color="#33393e"/><stop offset="1" stop-color="#373c42"/></linearGradient>
<linearGradient id="g7" gradientUnits="userSpaceOnUse" x1="364.2" y1="664.4" x2="360.7" y2="746.1"><stop offset="0" stop-color="#353b40"/><stop offset="1" stop-color="#40474d"/></linearGradient>
<linearGradient id="g8" gradientUnits="userSpaceOnUse" x1="965.4" y1="697.5" x2="906.6" y2="706.0"><stop offset="0" stop-color="#63686c"/><stop offset="1" stop-color="#676c70"/></linearGradient>
<linearGradient id="g9" gradientUnits="userSpaceOnUse" x1="894.1" y1="714.4" x2="875.2" y2="729.2"><stop offset="0" stop-color="#43474a"/><stop offset="1" stop-color="#45474b"/></linearGradient>
<linearGradient id="g10" gradientUnits="userSpaceOnUse" x1="682.0" y1="67.3" x2="737.5" y2="95.0"><stop offset="0" stop-color="#d5c9bc"/><stop offset="1" stop-color="#dbcdc1"/></linearGradient>
<linearGradient id="g11" gradientUnits="userSpaceOnUse" x1="979.3" y1="183.9" x2="958.7" y2="176.5"><stop offset="0" stop-color="#9b9187"/><stop offset="1" stop-color="#b9aea5"/></linearGradient>
<linearGradient id="g12" gradientUnits="userSpaceOnUse" x1="924.1" y1="150.5" x2="852.7" y2="205.4"><stop offset="0" stop-color="#c2b6a8"/><stop offset="1" stop-color="#f1e7d9"/></linearGradient>
<linearGradient id="g13" gradientUnits="userSpaceOnUse" x1="663.8" y1="225.3" x2="726.2" y2="233.1"><stop offset="0" stop-color="#d4c4b2"/><stop offset="1" stop-color="#dccebd"/></linearGradient>
<linearGradient id="g14" gradientUnits="userSpaceOnUse" x1="945.6" y1="245.5" x2="885.2" y2="257.5"><stop offset="0" stop-color="#837b70"/><stop offset="1" stop-color="#b0a292"/></linearGradient>
<linearGradient id="g15" gradientUnits="userSpaceOnUse" x1="889.0" y1="403.3" x2="817.3" y2="411.5"><stop offset="0" stop-color="#a1998c"/><stop offset="1" stop-color="#e4d7c8"/></linearGradient>
<linearGradient id="g16" gradientUnits="userSpaceOnUse" x1="886.4" y1="506.4" x2="918.1" y2="507.5"><stop offset="0" stop-color="#857f74"/><stop offset="1" stop-color="#8b8279"/></linearGradient>
<linearGradient id="g17" gradientUnits="userSpaceOnUse" x1="881.7" y1="569.7" x2="827.2" y2="506.1"><stop offset="0" stop-color="#888175"/><stop offset="1" stop-color="#a49b8d"/></linearGradient>
<linearGradient id="g18" gradientUnits="userSpaceOnUse" x1="208.2" y1="559.7" x2="242.4" y2="555.4"><stop offset="0" stop-color="#aa9c8d"/><stop offset="1" stop-color="#b3a693"/></linearGradient>
<linearGradient id="g19" gradientUnits="userSpaceOnUse" x1="852.5" y1="644.9" x2="736.7" y2="633.3"><stop offset="0" stop-color="#928a7d"/><stop offset="1" stop-color="#daccbc"/></linearGradient>
<linearGradient id="g20" gradientUnits="userSpaceOnUse" x1="224.4" y1="631.6" x2="303.9" y2="662.7"><stop offset="0" stop-color="#8d8376"/><stop offset="1" stop-color="#b0a396"/></linearGradient>
<linearGradient id="g21" gradientUnits="userSpaceOnUse" x1="1017.2" y1="708.7" x2="974.7" y2="752.6"><stop offset="0" stop-color="#726b60"/><stop offset="1" stop-color="#867e73"/></linearGradient>
<linearGradient id="g22" gradientUnits="userSpaceOnUse" x1="560.7" y1="705.9" x2="600.9" y2="763.2"><stop offset="0" stop-color="#898072"/><stop offset="1" stop-color="#a89b8b"/></linearGradient>
<linearGradient id="g23" gradientUnits="userSpaceOnUse" x1="801.0" y1="120.8" x2="796.8" y2="140.0"><stop offset="0" stop-color="#e5e4e4"/><stop offset="1" stop-color="#eff0ef"/></linearGradient>
<linearGradient id="g24" gradientUnits="userSpaceOnUse" x1="795.2" y1="187.5" x2="744.9" y2="264.6"><stop offset="0" stop-color="#d6dad6"/><stop offset="1" stop-color="#e7e8e7"/></linearGradient>
<linearGradient id="g25" gradientUnits="userSpaceOnUse" x1="444.3" y1="360.6" x2="343.6" y2="248.4"><stop offset="0" stop-color="#eaeaea"/><stop offset="1" stop-color="#f1f1f0"/></linearGradient>
<linearGradient id="g26" gradientUnits="userSpaceOnUse" x1="774.0" y1="291.5" x2="756.7" y2="328.6"><stop offset="0" stop-color="#e0e1de"/><stop offset="1" stop-color="#e7e8e5"/></linearGradient>
<linearGradient id="g27" gradientUnits="userSpaceOnUse" x1="504.5" y1="428.2" x2="600.5" y2="343.0"><stop offset="0" stop-color="#e6e8e7"/><stop offset="1" stop-color="#e9eae8"/></linearGradient>
<linearGradient id="g28" gradientUnits="userSpaceOnUse" x1="270.4" y1="455.9" x2="305.1" y2="499.0"><stop offset="0" stop-color="#e7e8e7"/><stop offset="1" stop-color="#ececeb"/></linearGradient>
<linearGradient id="g29" gradientUnits="userSpaceOnUse" x1="780.3" y1="469.6" x2="741.5" y2="534.9"><stop offset="0" stop-color="#dbdfdb"/><stop offset="1" stop-color="#e6e7e4"/></linearGradient>
<linearGradient id="g30" gradientUnits="userSpaceOnUse" x1="572.4" y1="460.6" x2="554.8" y2="532.2"><stop offset="0" stop-color="#e1e3e0"/><stop offset="1" stop-color="#eaeae9"/></linearGradient>
<linearGradient id="g31" gradientUnits="userSpaceOnUse" x1="373.0" y1="591.4" x2="450.6" y2="588.1"><stop offset="0" stop-color="#e1e4e0"/><stop offset="1" stop-color="#e5e7e4"/></linearGradient>
<linearGradient id="g32" gradientUnits="userSpaceOnUse" x1="634.8" y1="522.2" x2="694.0" y2="646.0"><stop offset="0" stop-color="#cfd5d1"/><stop offset="1" stop-color="#d8ddd9"/></linearGradient>
<linearGradient id="g33" gradientUnits="userSpaceOnUse" x1="488.1" y1="692.3" x2="494.0" y2="725.6"><stop offset="0" stop-color="#d5dad6"/><stop offset="1" stop-color="#e2e5e2"/></linearGradient>
<filter id="shadow" x="-10%" y="-10%" width="130%" height="130%"><feDropShadow dx="3" dy="5" stdDeviation="4" flood-color="#000" flood-opacity="0.18"/></filter>
<filter id="brush" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency="0.003 0.7" numOctaves="2" seed="7" result="noise"/><feColorMatrix in="noise" type="saturate" values="0" result="grey"/><feComponentTransfer in="grey" result="grain"><feFuncA type="linear" slope="0.22"/></feComponentTransfer><feComposite in="grain" in2="SourceGraphic" operator="in" result="clipped"/><feBlend in="clipped" in2="SourceGraphic" mode="overlay"/></filter>
</defs>
<title>Singe dragon</title>
<g filter="url(#shadow)">
<polygon points="620.3,450.0 420.1,406.2 575.1,277.9 680.5,456.9" fill="url(#g27)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="718.9,692.9 547.2,565.9 642.9,498.6 688.8,474.3 721.0,511.5" fill="url(#g32)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="832.0,437.8 829.7,543.9 705.5,374.0 673.4,303.6 704.9,256.9" fill="url(#g4)" stroke="#aaacae" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="573.2,269.1 519.7,314.7 404.5,263.3 302.7,135.5" fill="url(#g1)" stroke="#c4c5c7" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="281.8,224.7 375.5,258.3 510.4,318.9 414.4,400.8" fill="url(#g25)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="732.1,520.7 894.5,701.6 730.0,691.8" fill="url(#g19)" stroke="#ece5dd" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="412.5,697.6 353.4,656.7 383.4,543.9 473.3,459.7" fill="url(#g31)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="320.8,754.9 288.6,624.8 486.2,759.6" fill="url(#g7)" stroke="#9fa3a6" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="481.8,461.2 539.9,571.7 415.1,701.2" fill="url(#g5)" stroke="#acb0b2" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="785.2,280.5 915.9,436.6 874.0,506.8 815.2,407.5" fill="url(#g15)" stroke="#f1ebe3" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="674.6,467.0 539.5,557.8 484.1,450.4" fill="url(#g30)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="829.1,553.8 825.4,615.9 692.3,464.8 719.1,401.9" fill="url(#g29)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="346.0,386.3 325.7,487.9 266.2,553.8 224.8,479.9" fill="url(#g28)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="266.0,555.8 310.0,745.6 202.7,639.4" fill="url(#g20)" stroke="#d7d1ca" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="772.9,160.4 839.1,206.4 782.1,265.6 730.4,279.6 711.1,253.1" fill="url(#g24)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="481.7,755.7 587.6,673.5 660.8,760.2" fill="url(#g22)" stroke="#d3cdc5" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="656.2,206.4 767.6,153.0 644.6,335.1" fill="url(#g13)" stroke="#ede6de" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="837.1,618.3 834.8,444.4 906.2,561.9" fill="url(#g17)" stroke="#d1cdc6" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="956.0,158.3 956.0,190.3 862.2,209.3 774.5,150.0 819.0,146.0" fill="url(#g12)" stroke="#f8f3ec" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="933.1,751.6 995.8,671.8 1073.3,758.9" fill="url(#g21)" stroke="#c2beb9" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="778.2,149.8 640.3,144.9 552.5,57.2" fill="url(#g0)" stroke="#cbcacc" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="928.4,545.6 882.6,680.7 833.3,626.6" fill="url(#g6)" stroke="#9b9da0" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="421.4,698.7 579.0,670.2 483.0,746.1" fill="url(#g33)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="883.2,680.4 990.9,673.4 926.5,753.0" fill="url(#g8)" stroke="#b3b5b7" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="930.3,539.5 906.2,561.1 871.3,508.2 912.0,435.2" fill="url(#g16)" stroke="#c5c0bc" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="257.8,560.4 201.9,632.0 214.1,482.6" fill="url(#g18)" stroke="#d9d2c9" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="783.1,149.1 712.8,111.3 830.2,116.0 874.7,144.4" fill="url(#g23)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="940.3,291.2 842.3,211.4 966.9,239.2" fill="url(#g14)" stroke="#d7d0c8" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="887.2,248.1 785.9,269.7 841.6,212.7" fill="url(#g3)" stroke="#acaeb1" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="771.8,272.5 797.9,379.7 733.9,288.2" fill="url(#g26)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="700.8,100.8 653.0,30.8 778.0,107.7" fill="url(#g10)" stroke="#ede6e0" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="826.6,702.0 893.1,702.0 934.9,762.2" fill="url(#g9)" stroke="#a2a3a5" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="954.9,193.0 958.8,135.4 989.6,179.7 970.4,218.2" fill="url(#g11)" stroke="#dcd6d2" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="825.0,149.0 887.9,149.0 854.9,174.1" fill="url(#g2)" stroke="#b9bbbd" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 13 KiB

BIN
assets/DragonModel.glb (Stored with Git LFS)

Binary file not shown.

View file

@ -1,34 +0,0 @@
# Materials for DragonModel.obj
newmtl dark
Ka 0.060 0.062 0.066
Kd 0.300 0.310 0.330
Ks 0.700 0.700 0.700
Ns 180.0
d 1.00
Ni 1.00
illum 2
Pm 0.80
Pr 0.35
newmtl champagne
Ka 0.144 0.128 0.108
Kd 0.720 0.640 0.540
Ks 0.850 0.800 0.700
Ns 120.0
d 1.00
Ni 1.00
illum 2
Pm 0.80
Pr 0.40
newmtl glass
Ka 0.176 0.184 0.180
Kd 0.880 0.920 0.900
Ks 0.950 0.950 0.950
Ns 300.0
d 0.55
Ni 1.50
illum 4
Pm 0.00
Pr 0.15

File diff suppressed because it is too large Load diff

View file

@ -1,27 +0,0 @@
{
"_comment": [
"Where each of the dragon's parts turns, for util/objToGlb.py --pivots.",
"Each entry is min, mid or max of that part's own bounding box, per axis, or a number.",
"The dragon faces +X, +Y is up, and the wings reach out along -Z and +Z.",
"Derived from the anatomy in util/dragonModel.py: a joint sits where the part meets the",
"one it hangs from, so a wing turns at the shoulder and the head at the top of the neck.",
"Without these every part's node sits at the model's origin and rotating one swings it",
"around the middle of the animal."
],
"head": ["min", "mid", "mid"],
"neck": ["mid", "min", "mid"],
"tail": ["max", "mid", "mid"],
"wingL": ["max", "mid", "max"],
"wingR": ["max", "mid", "min"],
"foreLegL": ["mid", "max", "mid"],
"foreLegR": ["mid", "max", "mid"],
"hindLegL": ["mid", "max", "mid"],
"hindLegR": ["mid", "max", "mid"],
"hornL": ["mid", "min", "mid"],
"hornR": ["mid", "min", "mid"],
"crest": ["mid", "min", "mid"]
}

BIN
assets/DragonPrint-champagne.stl (Stored with Git LFS)

Binary file not shown.

BIN
assets/DragonPrint-dark.stl (Stored with Git LFS)

Binary file not shown.

BIN
assets/DragonPrint-frame.stl (Stored with Git LFS)

Binary file not shown.

BIN
assets/DragonPrint-glass.stl (Stored with Git LFS)

Binary file not shown.

View file

@ -1,45 +0,0 @@
# Materials for DragonPrint.obj
newmtl dark
Ka 0.060 0.062 0.066
Kd 0.300 0.310 0.330
Ks 0.700 0.700 0.700
Ns 180.0
d 1.00
Ni 1.00
illum 2
Pm 0.80
Pr 0.35
newmtl champagne
Ka 0.144 0.128 0.108
Kd 0.720 0.640 0.540
Ks 0.850 0.800 0.700
Ns 120.0
d 1.00
Ni 1.00
illum 2
Pm 0.80
Pr 0.40
newmtl glass
Ka 0.176 0.184 0.180
Kd 0.880 0.920 0.900
Ks 0.950 0.950 0.950
Ns 300.0
d 0.55
Ni 1.50
illum 4
Pm 0.00
Pr 0.15
newmtl frame
Ka 0.024 0.022 0.020
Kd 0.120 0.110 0.100
Ks 0.300 0.300 0.300
Ns 40.0
d 1.00
Ni 1.00
illum 2
Pm 0.00
Pr 0.70

File diff suppressed because it is too large Load diff

BIN
assets/DragonRelief.glb (Stored with Git LFS)

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -1,864 +0,0 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program 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; either version 3
* of the License, or (at your option) any later version.
*
* This program 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. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- The compiler: a game description to a Singe game (FORGE.md).
--
-- A description is data -- types, rooms, and rules -- and what comes out is a Lua script that
-- calls the runtime in Author.singe. The rules become real functions: a condition is a Lua
-- expression, an action a statement, and a rule with an action that takes time a coroutine. The
-- description travels with the game so it can be opened again; nothing is lost in either
-- direction.
--
-- Written in Lua rather than as a util/ script because the editor is itself a Singe game and has to
-- compile what it is editing without leaving the engine. It lives with Forge rather than in Singe/
-- because only building needs it; Author.singe, the runtime a built game loads, travels with the
-- game -- a copy is written beside it when it is built -- so a game handed to someone who has never
-- installed Forge still runs.
-- Where the runtime is taken from when a game is built. Forge carries it; a game gets a copy.
AUTHOR_RUNTIME = "Forge/Author.singe"
dofile(AUTHOR_RUNTIME)
local INDENT = "\t"
-- ===== Expressions ============================================================================
--
-- A small, safe language where a number goes: numbers, strings, arithmetic, comparison, and, or,
-- not, self.var, other.var, event.field, an entity's field by name, the game's vars by name, and a
-- few functions. Anything else is a compile error naming the expression, and the lua action is
-- the way past it.
local FUNCTIONS = {
count = "authorCount",
random = "authorRandom",
distance = "authorDistance",
has = "authorHas",
abs = "math.abs",
min = "math.min",
max = "math.max",
floor = "math.floor",
time = "authorTime",
frame = "discGetFrame"
}
local NAMES = {
self = "self",
other = "other",
event = "event",
time = "authorTime()",
frame = "discGetFrame()"
}
local OPERATORS = {
["=="] = "==", ["~="] = "~=", ["!="] = "~=", ["<="] = "<=", [">="] = ">=", ["<"] = "<", [">"] = ">",
["+"] = "+", ["-"] = "-", ["*"] = "*", ["/"] = "/", ["%"] = "%", ["^"] = "^", [".."] = ".."
}
-- The keys of a table in name order, so anything written from one is written the same way each time.
local function sortedKeys(t)
local keys = {}
for key in pairs(t or {}) do
keys[#keys + 1] = key
end
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
return keys
end
local function tokenize(text)
local tokens = {}
local at = 1
while at <= #text do
local c = text:sub(at, at)
if c:match("%s") then
at = at + 1
elseif c:match("[%d%.]") and text:sub(at):match("^%d") or text:sub(at):match("^%.%d") then
local number = text:sub(at):match("^%d*%.?%d+[eE][-+]?%d+") or text:sub(at):match("^%d*%.?%d+") or text:sub(at):match("^%d+")
tokens[#tokens + 1] = { kind = "number", value = number }
at = at + #number
elseif c:match("[%a_]") then
local name = text:sub(at):match("^[%a_][%w_]*")
tokens[#tokens + 1] = { kind = "name", value = name }
at = at + #name
elseif (c == '"') or (c == "'") then
-- A backslash escapes the next character, so a line can hold its own quote marks.
local value = {}
local scan = at + 1
while (scan <= #text) and (text:sub(scan, scan) ~= c) do
local ch = text:sub(scan, scan)
if ch == "\\" then
local escaped = text:sub(scan + 1, scan + 1)
value[#value + 1] = (escaped == "n") and "\n" or escaped
scan = scan + 2
else
value[#value + 1] = ch
scan = scan + 1
end
end
if scan > #text then
error("unterminated string in: " .. text)
end
tokens[#tokens + 1] = { kind = "string", value = table.concat(value) }
at = scan + 1
else
local two = text:sub(at, at + 1)
if OPERATORS[two] then
tokens[#tokens + 1] = { kind = "op", value = two }
at = at + 2
elseif OPERATORS[c] or c == "(" or c == ")" or c == "," or c == "." then
tokens[#tokens + 1] = { kind = "op", value = c }
at = at + 1
else
error("cannot read '" .. c .. "' in: " .. text)
end
end
end
return tokens
end
-- Recursive descent, lowest precedence first. Every level returns Lua source.
local function parser(text)
local tokens = tokenize(text)
local at = 1
local p = {}
local function peek(kind, value)
local token = tokens[at]
return token and (token.kind == kind) and ((value == nil) or (token.value == value))
end
local function take()
at = at + 1
return tokens[at - 1]
end
local function expect(kind, value)
if not peek(kind, value) then
error("expected " .. (value or kind) .. " in: " .. text)
end
return take()
end
function p.primary()
local token = take()
if token == nil then
error("expression ends early: " .. text)
end
if token.kind == "number" then
return token.value
elseif token.kind == "string" then
return string.format("%q", token.value)
elseif token.kind == "op" and token.value == "(" then
local inner = p.expression()
expect("op", ")")
return "(" .. inner .. ")"
elseif token.kind == "op" and token.value == "-" then
return "(-" .. p.unary() .. ")"
elseif token.kind == "name" then
local name = token.value
if name == "true" or name == "false" or name == "nil" then
return name
end
if name == "not" then
return "(not " .. p.unary() .. ")"
end
if peek("op", "(") then
local target = FUNCTIONS[name]
local args = {}
if target == nil then
error("no function called " .. name .. " in: " .. text)
end
take()
if not peek("op", ")") then
args[#args + 1] = p.expression()
while peek("op", ",") do
take()
args[#args + 1] = p.expression()
end
end
expect("op", ")")
return target .. "(" .. table.concat(args, ", ") .. ")"
end
if peek("op", ".") then
take()
local field = expect("name").value
if name == "event" then
return "event[" .. string.format("%q", field) .. "]"
elseif NAMES[name] and (name == "self" or name == "other") then
return "authorField(" .. name .. ", " .. string.format("%q", field) .. ")"
end
return "authorField(authorEntity(" .. string.format("%q", name) .. "), " .. string.format("%q", field) .. ")"
end
if NAMES[name] then
return NAMES[name]
end
return "AUTHOR_VARS[" .. string.format("%q", name) .. "]"
end
error("unexpected '" .. tostring(token.value) .. "' in: " .. text)
end
function p.unary()
return p.primary()
end
function p.power()
local left = p.unary()
while peek("op", "^") do
take()
left = "(" .. left .. " ^ " .. p.unary() .. ")"
end
return left
end
function p.product()
local left = p.power()
while peek("op", "*") or peek("op", "/") or peek("op", "%") do
local op = take().value
left = "(" .. left .. " " .. op .. " " .. p.power() .. ")"
end
return left
end
function p.sum()
local left = p.product()
while peek("op", "+") or peek("op", "-") or peek("op", "..") do
local op = take().value
left = "(" .. left .. " " .. op .. " " .. p.product() .. ")"
end
return left
end
function p.comparison()
local left = p.sum()
while peek("op") and OPERATORS[tokens[at].value] and tokens[at].value:match("[=<>~!]") do
local op = OPERATORS[take().value]
left = "(" .. left .. " " .. op .. " " .. p.sum() .. ")"
end
return left
end
function p.conjunction()
local left = p.comparison()
while peek("name", "and") do
take()
left = "(" .. left .. " and " .. p.comparison() .. ")"
end
return left
end
function p.expression()
local left = p.conjunction()
while peek("name", "or") do
take()
left = "(" .. left .. " or " .. p.conjunction() .. ")"
end
return left
end
local out = p.expression()
if at <= #tokens then
error("unexpected '" .. tostring(tokens[at].value) .. "' in: " .. text)
end
return out
end
-- An expression as Lua. A number or a boolean given as a value is itself.
function authorExpression(value)
if type(value) == "number" then
return tostring(value)
elseif type(value) == "boolean" then
return tostring(value)
elseif value == nil then
return "nil"
end
return parser(tostring(value))
end
-- ===== Parameters =============================================================================
--
-- A parameter's value as a Lua fragment, by the type the manifest declares for it.
local function fragment(kind, value)
if value == nil then
return nil
end
if kind == "number" or kind == "expression" then
return authorExpression(value)
elseif kind == "boolean" then
return value and "true" or "false"
elseif kind == "entity" then
if value == "self" or value == "other" then
return value
end
return "authorEntity(" .. string.format("%q", value) .. ")"
elseif kind == "scancode" then
return "SCANCODE." .. value .. ".value"
elseif kind == "switch" then
return value
elseif kind == "lua" then
return value
end
return string.format("%q", tostring(value))
end
-- A part's parameters as Lua. One the author left empty takes the manifest's default for it,
-- and one with neither becomes nil, which the checker has already pointed out.
local function fragments(entry, item)
local out = {}
for key, kind in pairs(entry.params or {}) do
local value = item[key]
if (value == nil) and entry.defaults then
value = entry.defaults[key]
end
out[key] = fragment(kind, value) or "nil"
end
return out
end
-- ===== Rules ==================================================================================
local function emitOne(set, item, what)
local name = item[1]
local entry = set[name]
if entry == nil then
debugPrint("Author: no " .. what .. " called '" .. tostring(name) .. "'; skipped")
return nil
end
return entry.emit(fragments(entry, item))
end
-- A condition list is an AND; an entry named any is an OR of what it holds, and an entry named
-- all an AND, nesting as deep as the author likes.
local function emitConditions(list)
local parts = {}
for _, item in ipairs(list or {}) do
local test = emitOne(AUTHOR.conditions, item, "condition")
if test ~= nil then
parts[#parts + 1] = test
end
end
if list and list.any then
local inner = {}
for _, item in ipairs(list.any) do
if item[1] then
local test = emitOne(AUTHOR.conditions, item, "condition")
if test then
inner[#inner + 1] = test
end
else
inner[#inner + 1] = emitConditions(item)
end
end
if #inner > 0 then
parts[#parts + 1] = "(" .. table.concat(inner, " or ") .. ")"
end
end
if list and list.all then
parts[#parts + 1] = emitConditions(list.all)
end
if #parts == 0 then
return "true"
end
return table.concat(parts, " and ")
end
local function ruleWaits(rule)
for _, item in ipairs(rule.act or {}) do
local entry = AUTHOR.actions[item[1]]
if entry and entry.waits then
return true
end
end
return false
end
local function emitRule(out, rule, index)
local event = AUTHOR.events[rule.on or "frame"]
local filter = {}
if event == nil then
debugPrint("Author: no event called '" .. tostring(rule.on) .. "'; rule " .. index .. " skipped")
return
end
for key, kind in pairs(event.filter) do
if rule[key] ~= nil then
filter[#filter + 1] = key .. " = " .. fragment(kind, rule[key])
end
end
out[#out + 1] = INDENT .. "{"
out[#out + 1] = INDENT .. INDENT .. string.format("note = %q, on = %q,", rule.note or ("rule " .. index), rule.on or "frame")
out[#out + 1] = INDENT .. INDENT .. "filter = { " .. table.concat(filter, ", ") .. " },"
out[#out + 1] = INDENT .. INDENT .. string.format("each = %s, room = %s, waits = %s, controls = %s, interrupt = %s, guarded = %s,",
rule.each and string.format("%q", rule.each) or "nil",
rule.room and string.format("%q", rule.room) or "nil",
tostring(ruleWaits(rule)),
tostring(rule.controls ~= false),
tostring(rule.interrupt == true),
tostring((rule.when ~= nil) and ((#rule.when > 0) or (rule.when.any ~= nil) or (rule.when.all ~= nil))))
out[#out + 1] = INDENT .. INDENT .. "run = function(self, other, event)"
out[#out + 1] = INDENT .. INDENT .. INDENT .. "if " .. emitConditions(rule.when) .. " then"
for _, item in ipairs(rule.act or {}) do
local statement = emitOne(AUTHOR.actions, item, "action")
if statement ~= nil then
out[#out + 1] = INDENT .. INDENT .. INDENT .. INDENT .. statement
end
end
out[#out + 1] = INDENT .. INDENT .. INDENT .. INDENT .. "return true"
out[#out + 1] = INDENT .. INDENT .. INDENT .. "end"
out[#out + 1] = INDENT .. INDENT .. INDENT .. "return false"
out[#out + 1] = INDENT .. INDENT .. "end"
out[#out + 1] = INDENT .. "},"
end
-- A dialogue: its start and its nodes; a choice's when is an expression and its act a list of
-- actions, compiled as a rule's are, with self and other absent.
local function emitDialogue(out, name, dialogue)
out[#out + 1] = INDENT .. string.format("[%q] = { start = %q, nodes = {", name, dialogue.start or "start")
-- Nodes in name order, so the same description compiles to the same text every time.
for _, nodeName in ipairs(sortedKeys(dialogue.nodes or {})) do
local node = dialogue.nodes[nodeName]
out[#out + 1] = INDENT .. INDENT .. string.format("[%q] = { name = %q, who = %s, text = %s, seconds = %s, next = %s, choices = {",
nodeName, nodeName,
node.who and string.format("%q", node.who) or "nil",
node.text and string.format("%q", node.text) or "nil",
node.seconds and tostring(node.seconds) or "nil",
node.next and string.format("%q", node.next) or "nil")
for _, choice in ipairs(node.choices or {}) do
local parts = { string.format("text = %q", choice.text or "..."), "next = " .. (choice.next and string.format("%q", choice.next) or "nil"), "once = " .. tostring(choice.once == true) }
if choice.when then
parts[#parts + 1] = "when = function() local self, other, event = nil, nil, {} return " .. emitConditions(type(choice.when) == "table" and choice.when or { { "test", expr = choice.when } }) .. " end"
end
if choice.act then
local body = {}
for _, item in ipairs(choice.act) do
local statement = emitOne(AUTHOR.actions, item, "action")
if statement then
body[#body + 1] = statement
end
end
parts[#parts + 1] = "run = function() local self, other, event = nil, nil, {} " .. table.concat(body, " ") .. " end"
end
out[#out + 1] = INDENT .. INDENT .. INDENT .. "{ " .. table.concat(parts, ", ") .. " },"
end
out[#out + 1] = INDENT .. INDENT .. "} },"
end
out[#out + 1] = INDENT .. "} },"
end
-- ===== Reading and writing a description ======================================================
--
-- The editor produces descriptions, so the format has to survive a round trip: load, change
-- nothing, save, and the result must compile to the same game.
local function literal(value)
if type(value) == "string" then
return string.format("%q", value)
end
return tostring(value)
end
local function indentOf(depth)
return string.rep(INDENT, depth)
end
-- Whether a table holds only leaves, in which case it is written on one line. A condition reads
-- far better as { "keyHeld", key = "LEFT" } than as six lines, and it is what an author typed.
local function isLeaf(t)
for _, value in pairs(t) do
if type(value) == "table" then
return false
end
end
return true
end
-- A value written back as Lua source. A condition or an action is a mixed table -- an array part
-- naming it, plus named parameters -- so both parts have to be written or the name survives and
-- the parameters do not. The array part keeps its order; the named keys are sorted, because a
-- description that reorders itself on every save makes every diff unreadable.
local function valueSource(value, depth)
local parts = {}
local keys = {}
local count = 0
if type(value) ~= "table" then
return literal(value)
end
count = #value
for _, item in ipairs(value) do
parts[#parts + 1] = valueSource(item, depth + 1)
end
for key in pairs(value) do
-- Skip the array part, which has already been written in order.
if not (type(key) == "number" and key >= 1 and key <= count and key == math.floor(key)) then
keys[#keys + 1] = key
end
end
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
for _, key in ipairs(keys) do
local name = tostring(key)
if not name:match("^[%a_][%w_]*$") then
name = "[" .. literal(key) .. "]"
end
parts[#parts + 1] = string.format("%s = %s", name, valueSource(value[key], depth + 1))
end
if #parts == 0 then
return "{}"
end
if isLeaf(value) then
return "{ " .. table.concat(parts, ", ") .. " }"
end
return "{\n" .. indentOf(depth + 1) .. table.concat(parts, ",\n" .. indentOf(depth + 1)) .. "\n" .. indentOf(depth) .. "}"
end
function authorLoad(path)
local chunk, problem = loadfile(path)
if chunk == nil then
debugPrint("Author: cannot read " .. tostring(path) .. ": " .. tostring(problem))
return nil
end
return chunk()
end
function authorSave(game, path)
local file = assert(io.open(path, "w"))
local copy = {}
-- source says where a description came from and is set by the loader, so writing it back out
-- would make a description that had been through the editor differ from one that had not.
for key in pairs(game) do
if key ~= "source" then
copy[key] = game[key]
end
end
file:write("-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine.\n")
file:write("return " .. valueSource(copy, 0) .. "\n")
file:close()
return path
end
function authorCopy(fromPath, toPath)
local input = io.open(fromPath, "rb")
local output
if input == nil then
return false
end
output = io.open(toPath, "wb")
if output == nil then
input:close()
return false
end
output:write(input:read("a"))
input:close()
output:close()
return true
end
-- ===== Checking ===============================================================================
--
-- What the compiler can tell an author before the game runs: a type that is not declared, a
-- condition the manifest does not have, an expression that does not parse. Returns a list of
-- messages, empty when the description is sound.
function authorCheck(game)
local problems = {}
local types = game.types or {}
local function problem(text)
problems[#problems + 1] = text
end
local function checkItems(set, list, what, where)
for _, item in ipairs(list or {}) do
local entry = set[item[1]]
if entry == nil then
problem(where .. ": no " .. what .. " called '" .. tostring(item[1]) .. "'")
else
for key, kind in pairs(entry.params) do
if (kind == "number" or kind == "expression") and (type(item[key]) == "string") then
local ok, err = pcall(parser, item[key])
if not ok then
problem(where .. ": " .. tostring(err))
end
elseif (kind == "type") and (item[key] ~= nil) and (types[item[key]] == nil) then
problem(where .. ": no type called '" .. tostring(item[key]) .. "'")
elseif (item[key] == nil) and (kind ~= "entity") and ((entry.defaults or {})[key] == nil) and not (entry.optional or {})[key] then
problem(where .. ": " .. key .. " is empty")
end
end
end
end
end
-- A field the manifest does not declare is a misspelling nine times in ten, and it is
-- otherwise silently ignored.
local function checkFields(t, declared, skip, where)
for key in pairs(t or {}) do
if (type(key) == "string") and (declared[key] == nil) and not skip[key] then
problem(where .. ": '" .. key .. "' is not something it takes")
end
end
end
for name, entityType in pairs(types) do
local look = entityType.look and AUTHOR.looks[entityType.look.kind] or nil
if entityType.look and (look == nil) then
problem("type " .. name .. ": no look called '" .. tostring(entityType.look.kind) .. "'")
elseif look then
checkFields(entityType.look, look.params, { kind = true }, "type " .. name .. "'s look")
end
for _, b in ipairs(entityType.behaviours or {}) do
local behaviour = AUTHOR.behaviours[b.kind]
if behaviour == nil then
problem("type " .. name .. ": no behaviour called '" .. tostring(b.kind) .. "'")
else
checkFields(b, behaviour.params, { kind = true }, "type " .. name .. "'s " .. b.kind)
end
end
checkFields(entityType, { look = true, vars = true, behaviours = true }, {}, "type " .. name)
end
if #(game.rooms or {}) == 0 then
problem("the game has no rooms")
end
for _, room in ipairs(game.rooms or {}) do
for _, entry in ipairs(room.entities or {}) do
if types[entry.type] == nil then
problem("room " .. tostring(room.name) .. ": no type called '" .. tostring(entry.type) .. "'")
end
end
-- A waves track's keys name a type, and are at a moment -- or at a stop, when keyed by one.
for _, track in ipairs(room.tracks or {}) do
for index, key in ipairs(track.spawns or {}) do
local where = "room " .. tostring(room.name) .. ", track " .. tostring(track.name) .. ", wave " .. index
if types[key.type] == nil then
problem(where .. ": no type called '" .. tostring(key.type) .. "'")
end
if (track.key == "stop") and (type(key.at) ~= "string") then
problem(where .. ": at should name a stop")
elseif (track.key ~= "stop") and (type(key.at) ~= "number") then
problem(where .. ": at should be a number")
end
end
end
end
for index, rule in ipairs(game.rules or {}) do
local where = "rule " .. index .. " (" .. tostring(rule.note or "") .. ")"
if rule.on and (AUTHOR.events[rule.on] == nil) then
problem(where .. ": no event called '" .. tostring(rule.on) .. "'")
elseif AUTHOR.events[rule.on or "frame"] then
checkFields(rule, AUTHOR.events[rule.on or "frame"].filter, { note = true, on = true, each = true, room = true, when = true, act = true, controls = true, interrupt = true }, where)
end
if rule.each and (types[rule.each] == nil) then
problem(where .. ": no type called '" .. tostring(rule.each) .. "'")
end
checkItems(AUTHOR.conditions, rule.when, "condition", where)
checkItems(AUTHOR.conditions, rule.when and rule.when.any, "condition", where)
checkItems(AUTHOR.actions, rule.act, "action", where)
end
return problems
end
-- ===== The whole game =========================================================================
-- The whole game, as source. Returns a string; the caller decides where it goes.
--
-- Every built game loads the runtime sitting beside it. There is no other copy to load: nothing
-- of Forge ships inside Singe, so Author.singe travels with the game that needs it. Framework is
-- the engine's, as it is for every game ever written for Singe.
function authorCompile(game)
local out = {}
local data = {}
for key, value in pairs(game) do
if (key ~= "rules") and (key ~= "source") and (key ~= "dialogues") then
data[key] = value
end
end
out[#out + 1] = "-- Generated by Forge/AuthorCompile.singe from " .. (game.source or "a game description")
out[#out + 1] = "-- " .. (game.title or "Untitled")
out[#out + 1] = "--"
out[#out + 1] = "-- This is an ordinary Singe game and can be edited by hand. Doing so and then"
out[#out + 1] = "-- recompiling the description will overwrite it, so keep one or the other."
out[#out + 1] = ""
out[#out + 1] = 'dofile("Singe/Framework.singe")'
-- The game finds its own directory rather than trusting DIR. Framework sets DIR from the
-- script the *engine was launched with*, which is this file only when the game is played
-- directly; a game reached by dofile -- a test, a launcher, a menu that previews it -- would
-- otherwise look for its runtime beside the caller and not find it. debug.getinfo names the
-- chunk actually running, either way.
out[#out + 1] = 'local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\\\])") or "")'
out[#out + 1] = 'dofile(here .. "Author.singe")'
out[#out + 1] = "AUTHOR_DIR = here -- Files the description names are looked for here first."
out[#out + 1] = ""
out[#out + 1] = "-- The description, as the runtime wants it: everything but the rules, which follow as code."
out[#out + 1] = "authorBegin(" .. valueSource(data, 0) .. ")"
out[#out + 1] = ""
out[#out + 1] = "-- The rules. Each is a trigger, its filter, and a function that tests the conditions and"
out[#out + 1] = "-- does the actions; one with an action that waits runs as a coroutine."
out[#out + 1] = "authorRules({"
for index, rule in ipairs(game.rules or {}) do
emitRule(out, rule, index)
end
out[#out + 1] = "})"
out[#out + 1] = ""
if game.dialogues then
out[#out + 1] = "-- The dialogues: nodes of a line and choices, each choice's condition and actions as code."
out[#out + 1] = "authorDialogues({"
for _, name in ipairs(sortedKeys(game.dialogues)) do
emitDialogue(out, name, game.dialogues[name])
end
out[#out + 1] = "})"
out[#out + 1] = ""
end
out[#out + 1] = "onKeyPressed = authorKeyDown"
out[#out + 1] = "onKeyReleased = authorKeyUp"
out[#out + 1] = "onInputPressed = authorSwitchDown"
out[#out + 1] = "onInputReleased = authorSwitchUp"
out[#out + 1] = "onMouseMoved = authorMouseMoved"
out[#out + 1] = "onCollision = authorCollision"
out[#out + 1] = "onTrigger = authorTrigger"
out[#out + 1] = "onNavArrived = authorNavArrived"
out[#out + 1] = "onMidiMessage = authorMidi"
out[#out + 1] = "onSoundCompleted = authorSoundDone"
out[#out + 1] = ""
out[#out + 1] = "function onOverlayUpdate()"
out[#out + 1] = INDENT .. "authorFrame()"
out[#out + 1] = ""
out[#out + 1] = INDENT .. "return OVERLAY_UPDATED"
out[#out + 1] = "end"
out[#out + 1] = ""
return table.concat(out, "\n")
end
local function directoryOf(path)
return (string.match(path, "^(.*[/\\])") or "")
end
-- Compiles a description file to a game beside it, with the runtime beside that, because that is
-- what the result loads. Returns the path written.
function authorBuild(descriptionFile, outputFile)
local chunk = assert(loadfile(descriptionFile))
local game = chunk()
local file
game.source = descriptionFile
for _, problem in ipairs(authorCheck(game)) do
debugPrint("Author: " .. problem)
end
file = assert(io.open(outputFile, "w"))
file:write(authorCompile(game))
file:close()
authorCopy(AUTHOR_RUNTIME, directoryOf(outputFile) .. "Author.singe")
return outputFile
end

View file

@ -1,63 +0,0 @@
<rml>
<!--
Singe 3
Copyright (C) 2026 Scott Duensing <scott@kangaroopunch.com>
Forge's chrome. Only the panels are RmlUi; the canvas beside them is drawn
into the overlay, because that is where Singe already tracks a pointer against rectangles.
pointer-events keeps the canvas clickable through the document.
Licensed under the GNU General Public License, version 3 or later.
-->
<head>
<title>forge</title>
<link type="text/rcss" href="Singe/gui.rcss"/>
<style>
/* The body is the whole overlay, so the panel's 100% is a height: without it the panel
was nothing tall, everything in it overflow, and overflow: auto on it hid it all. */
body { pointer-events: none; width: 100%; height: 100%; font-family: FreeSans; font-size: 13px; color: #e6e6ee; }
/* Rows stay focusable: RmlUi delivers a click to the nearest focusable element under the
pointer, so a row that could not take focus could not be clicked either. ENTER and SPACE
on a focused element only become a click when its tab-index is auto, and no row's is. */
/* The panel is a column: the grip and the title, then the list and the fields box, which
share what is left and each scroll (the wheel, or the bar) rather than pushing the other
off the screen. */
#panel { pointer-events: auto; position: absolute; left: 0px; top: 0px; width: 190px; height: 100%; background-color: #161620ff; display: flex; flex-direction: column; }
#grip, #title { flex: 0 0 auto; }
#list { flex: 0 1 auto; min-height: 0px; overflow-y: auto; }
scrollbarvertical { width: 5px; }
scrollbarvertical slidertrack { background-color: #1b1b28ff; }
scrollbarvertical sliderbar { background-color: #6a5a9aff; }
#grip { pointer-events: auto; background-color: #2a2a3cff; padding: 6px 10px; color: #ffcf4a; font-size: 15px; }
#grip:hover { background-color: #3a3a52ff; }
#title { margin: 0px 10px 8px 10px; color: #9aa0b4; }
.row { margin: 2px 8px; padding: 4px 6px; background-color: #242433ff; }
.row:hover { background-color: #32324aff; }
.row.selected { background-color: #c03c96ff; color: #ffffff; }
.thumb { display: inline-block; vertical-align: middle; margin-right: 6px; width: 24px; text-align: center; color: #ffcf4a; }
.thumb.dim { color: #6a6a80; }
.thumb.swatch { height: 16px; width: 16px; margin-left: 4px; margin-right: 10px; }
.part { margin: 1px 8px 1px 18px; padding: 2px 5px; background-color: #1b1b28ff; color: #aeb4c8; font-size: 12px; }
.part:hover { background-color: #2a2a40ff; color: #e6e6ee; }
.picked { background-color: #6a5a9aff; color: #ffffff; }
#title:hover { color: #ffcf4a; }
.part.here { background-color: #4a3a6aff; color: #ffffff; }
/* A field's list, unfolded under its row and floating over the rows beneath, as a dropdown
does: absolute with no top or left takes the place it would have had in the flow. */
.drop { position: absolute; z-index: 2; clip: none; width: 140px; margin: 0px 0px 0px 26px; padding: 2px 0px; background-color: #0b0b12ff; border-width: 1px; border-color: #6a5a9aff; }
.choice { margin: 0px; padding: 2px 8px; color: #aeb4c8; font-size: 12px; }
.choice:hover { background-color: #32324aff; color: #e6e6ee; }
.choice.selected { background-color: #c03c96ff; color: #ffffff; }
.choice.more { color: #6a6a80; }
.choice.more:hover { background-color: #0b0b12ff; color: #6a6a80; }
#detail { flex: 0 1 auto; min-height: 0px; overflow-y: auto; margin: 14px 8px; padding: 6px; background-color: #10101aff; color: #b9bfd4; }
</style>
</head>
<body id="forge">
<div id="panel">
<div id="grip">&#8801; Entities</div>
<div id="title"></div>
<div id="list"></div>
<div id="detail"></div>
</div>
</body>
</rml>

File diff suppressed because it is too large Load diff

View file

@ -1,25 +0,0 @@
-- Forge appears in the menu like anything else, so it is reached the same way a game is and packs
-- to Forge.game with -P. It needs no disc of its own; the menu background stands in, because the
-- engine wants a video and Forge draws over all of it anyway.
GAMES = {
{
TITLE = "Forge",
SCRIPT = "Forge/Forge.singe",
LEGACY_SPRITE_ARGS = false,
VIDEO = "Singe/menuBackground.mkv",
STRETCH = false,
NO_MOUSE = false,
RESOLUTION_X = 720,
RESOLUTION_Y = 480,
SINDEN_GUN = "",
ATTRACT = "Singe/menuBackground.mkv",
ATTRACT_START = 180,
ATTRACT_END = 359,
YEAR = 2026,
PLATFORM = "Singe",
DEVELOPER = "Kangaroo Punch Studios",
PUBLISHER = "Kangaroo Punch Studios",
GENRE = "Tool",
DESCRIPTION = "Build a game by describing it: place things, write rules, press play. Forge writes ordinary Singe Lua, so nothing it makes is locked inside it."
}
}

View file

@ -1,8 +0,0 @@
The Forge tutorial kit.
Everything here except fox.glb was drawn or synthesised by util/forgeKit.py in the
Singe source tree and is released under CC0: use it in your own games as you like.
fox.glb is the Fox from the Khronos glTF sample models, CC-BY 4.0: model by PixelMannen,
rigging and animation by tomkranis, glTF conversion by AsoboStudio and scurest.
A game that ships it should carry this credit.

View file

@ -1,11 +0,0 @@
The Forge tutorial kit, addressed from a game as Forge/kit/<name>.
hero.png a sprite sheet, 8 columns of 32 x 48, facing right: idle, walk x4, jump, fall, hit
enemy.png a sheet, 4 columns of 32 x 32, flapping
tiles.png a tile sheet, 4 columns of 32 x 32: grass, dirt, stone, brick
coin.png key.png crate.png door.png spike.png ship.png shot.png rock.png target.png stills
yard.png hall.png painted rooms, 720 x 480, for the adventures
jump.wav coin.wav shot.wav hit.wav click.wav door.wav clips
loop.ogg eight seconds of music that loops
clip.mkv twelve seconds of 720 x 480 video at 24 frames a second: a door opens at 3 s, a target rises at 6 s, a hand reaches at 9 s
fox.glb an animated model (see LICENSE)

BIN
assets/Forge/kit/click.wav (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/clip.mkv (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/coin.png (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/coin.wav (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/crate.png (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/door.png (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/door.wav (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/enemy.png (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/fox.glb (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/hall.png (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/hero.png (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/hit.wav (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/jump.wav (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/key.png (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/loop.ogg (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/rock.png (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/ship.png (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/shot.png (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/shot.wav (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/spike.png (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/target.png (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/tiles.png (Stored with Git LFS)

Binary file not shown.

BIN
assets/Forge/kit/yard.png (Stored with Git LFS)

Binary file not shown.

View file

@ -1,76 +0,0 @@
-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine.
return {
layers = {
{ gravity = 1500, kind = "world2d" }
},
rooms = {
{
entities = {
{ id = "ground", type = "ground", x = 360, y = 440 },
{ id = "hero", type = "hero", x = 200, y = 420 },
{ id = "readout", type = "readout", x = 12, y = 12 }
},
name = "start",
tracks = {}
}
},
rules = {
{
act = {
{ "run", direction = -1, entity = "hero" }
},
note = "Run left",
on = "frame",
when = {
{ "keyHeld", key = "LEFT" }
}
},
{
act = {
{ "run", direction = 1, entity = "hero" }
},
note = "Run right",
on = "frame",
when = {
{ "keyHeld", key = "RIGHT" }
}
},
{
act = {
{ "jump", entity = "hero" }
},
note = "Jump, with ground underfoot",
on = "frame",
when = {
{ "keyHeld", key = "SPACE" },
{ "onGround", entity = "hero" }
}
},
{
act = {
{ "setText", entity = "readout", text = "\"score \" .. score" }
},
note = "The readout, every frame",
on = "frame"
}
},
title = "New game",
types = {
ground = {
behaviours = {
{ kind = "solid" }
},
look = { b = 90, g = 70, h = 40, kind = "box", r = 60, w = 720 }
},
hero = {
behaviours = {
{ jump = 620, kind = "platformer", speed = 260 }
},
look = { anchor = "feet", b = 240, g = 90, h = 44, kind = "box", r = 90, w = 24 }
},
readout = {
look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" }
}
},
vars = { score = 0 }
}

View file

@ -1,144 +0,0 @@
-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine.
return {
layers = {
{ gravity = 1500, kind = "world2d" }
},
rooms = {
{
entities = {
{ id = "ground", type = "ground", x = 360, y = 440 },
{ id = "hero", type = "hero", x = 200, y = 420 },
{ id = "readout", type = "readout", x = 12, y = 12 },
{ id = "coin", type = "coin", x = 300, y = 392 },
{ id = "coin1", type = "coin", x = 420, y = 392 },
{ id = "coin2", type = "coin", x = 560, y = 300 },
{ id = "spike", type = "spike", x = 640, y = 420 },
{ id = "door", type = "door", x = 700, y = 420 }
},
name = "start",
tracks = {}
},
{
entities = {
{ id = "ground", type = "ground", x = 360, y = 440 },
{ id = "readout", type = "readout", x = 12, y = 12 },
{ id = "coin", type = "coin", x = 400, y = 392 }
},
name = "cave",
tracks = {}
}
},
rules = {
{
act = {
{ "run", direction = -1, entity = "hero" }
},
note = "Run left",
on = "frame",
when = {
{ "keyHeld", key = "LEFT" }
}
},
{
act = {
{ "run", direction = 1, entity = "hero" }
},
note = "Run right",
on = "frame",
when = {
{ "keyHeld", key = "RIGHT" }
}
},
{
act = {
{ "jump", entity = "hero" }
},
note = "Jump, with ground underfoot",
on = "frame",
when = {
{ "keyHeld", key = "SPACE" },
{ "onGround", entity = "hero" }
}
},
{
act = {
{ "setText", entity = "readout", text = "\"score \" .. score" }
},
note = "The readout, every frame",
on = "frame"
},
{
a = "coin",
act = {
{ "addScore", amount = 10 },
{ "destroy", entity = "self" },
{ "playSound", file = "Forge/kit/coin.wav" }
},
b = "hero",
note = "Take a coin",
on = "enter",
when = {}
},
{
a = "spike",
act = {
{ "moveTo", entity = "hero", x = 200, y = 380 },
{ "playSound", file = "Forge/kit/hit.wav" }
},
b = "hero",
note = "Spikes hurt",
on = "enter",
when = {}
},
{
a = "door",
act = {
{ "goTo", entity = "hero", room = "cave", x = 60, y = 380 }
},
b = "hero",
note = "Through the door",
on = "enter",
when = {}
}
},
title = "New game",
types = {
coin = {
behaviours = {
{ kind = "trigger" }
},
look = { file = "Forge/kit/coin.png", kind = "sprite" },
vars = {}
},
door = {
behaviours = {
{ kind = "trigger" }
},
look = { anchor = "feet", file = "Forge/kit/door.png", kind = "sprite" },
vars = {}
},
ground = {
behaviours = {
{ kind = "solid" }
},
look = { b = 90, g = 70, h = 40, kind = "box", r = 60, w = 720 }
},
hero = {
behaviours = {
{ jump = 620, kind = "platformer", speed = 260 }
},
look = { anchor = "feet", b = 240, g = 90, h = 44, kind = "box", r = 90, w = 24 }
},
readout = {
look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" }
},
spike = {
behaviours = {
{ kind = "trigger" }
},
look = { anchor = "feet", file = "Forge/kit/spike.png", kind = "sprite" },
vars = {}
}
},
vars = { score = 0 }
}

View file

@ -1,156 +0,0 @@
-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine.
return {
layers = {
{ gravity = 1500, kind = "world2d" }
},
rooms = {
{
entities = {
{ id = "ground", type = "ground", x = 360, y = 440 },
{ id = "hero", type = "hero", x = 200, y = 420 },
{ id = "readout", type = "readout", x = 12, y = 12 },
{ id = "coin", type = "coin", x = 300, y = 392 },
{ id = "coin1", type = "coin", x = 420, y = 392 },
{ id = "coin2", type = "coin", x = 560, y = 300 },
{ id = "spike", type = "spike", x = 640, y = 420 },
{ id = "door", type = "door", x = 700, y = 420 }
},
name = "start",
tracks = {}
},
{
entities = {
{ id = "ground", type = "ground", x = 360, y = 440 },
{ id = "readout", type = "readout", x = 12, y = 12 },
{ id = "coin", type = "coin", x = 400, y = 392 }
},
name = "cave",
tracks = {}
}
},
rules = {
{
act = {
{ "run", direction = -1, entity = "hero" }
},
note = "Run left",
on = "frame",
when = {
{ "keyHeld", key = "LEFT" }
}
},
{
act = {
{ "run", direction = 1, entity = "hero" }
},
note = "Run right",
on = "frame",
when = {
{ "keyHeld", key = "RIGHT" }
}
},
{
act = {
{ "jump", entity = "hero" },
{ "playSound", file = "Forge/kit/jump.wav" }
},
note = "Jump, with ground underfoot",
on = "frame",
when = {
{ "onGround", entity = "hero" },
{ "keyPressed", key = "SPACE" }
}
},
{
act = {
{ "setText", entity = "readout", text = "\"score \" .. score" }
},
note = "The readout, every frame",
on = "frame"
},
{
a = "coin",
act = {
{ "addScore", amount = 10 },
{ "destroy", entity = "self" },
{ "playSound", file = "Forge/kit/coin.wav" }
},
b = "hero",
note = "Take a coin",
on = "enter",
when = {}
},
{
a = "spike",
act = {
{ "moveTo", entity = "hero", x = 200, y = 380 },
{ "playSound", file = "Forge/kit/hit.wav" }
},
b = "hero",
note = "Spikes hurt",
on = "enter",
when = {}
},
{
a = "door",
act = {
{ "goTo", entity = "hero", room = "cave", x = 60, y = 380 }
},
b = "hero",
note = "Through the door",
on = "enter",
when = {}
},
{
act = {
{ "playMusic", file = "Forge/kit/loop.ogg" }
},
note = "Music",
on = "roomStart",
when = {}
}
},
title = "New game",
types = {
coin = {
behaviours = {
{ kind = "trigger" },
{ kind = "spin", rate = 180 }
},
look = { file = "Forge/kit/coin.png", kind = "sprite" },
vars = {}
},
door = {
behaviours = {
{ kind = "trigger" }
},
look = { anchor = "feet", file = "Forge/kit/door.png", kind = "sprite" },
vars = {}
},
ground = {
behaviours = {
{ kind = "solid" }
},
look = { b = 90, g = 70, h = 40, kind = "box", r = 60, w = 720 }
},
hero = {
behaviours = {
{ jump = 620, kind = "platformer", speed = 260 },
{ fps = 10, kind = "frames", states = "idle=1-1, walk=2-5, jump=6-6, fall=7-7" }
},
look = { file = "Forge/kit/hero.png", frames = 8, kind = "sprite" },
vars = {}
},
readout = {
look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" }
},
spike = {
behaviours = {
{ kind = "trigger" }
},
look = { anchor = "feet", file = "Forge/kit/spike.png", kind = "sprite" },
vars = {}
}
},
vars = { score = 0 }
}

View file

@ -1,135 +0,0 @@
-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine.
return {
types = {
hive = {
behaviours = {
{ every = 0.6, kind = "spawner", max = 6, spawns = "rock", total = 12 }
},
look = { kind = "none" },
vars = {}
},
readout = {
look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" }
},
rock = {
behaviours = {
{ kind = "drift", vy = 90 },
{ kind = "health", max = 2 },
{ death = "Forge/kit/hit.wav", kind = "sound" }
},
look = { file = "Forge/kit/rock.png", kind = "sprite" },
vars = { health = 2 }
},
ship = {
behaviours = {
{ down = "DOWN", fire = "SPACE", kind = "keys", left = "LEFT", right = "RIGHT", up = "UP" },
{ clamp = true, kind = "mover", speed = 260 },
{ kind = "shooter", offsetY = -14, rate = 0.15, spawns = "shot" },
{ keep = true, kind = "health", max = 1 },
{ hit = "Forge/kit/hit.wav", kind = "sound" }
},
look = { file = "Forge/kit/ship.png", kind = "sprite" },
vars = { health = 1 }
},
shot = {
behaviours = {
{ kind = "projectile", life = 3, vy = -520 },
{ kind = "sound", spawn = "Forge/kit/shot.wav" }
},
look = { file = "Forge/kit/shot.png", kind = "sprite" },
vars = {}
}
},
layers = {
{ kind = "overlay" }
},
rooms = {
{
entities = {
{ id = "readout", type = "readout", x = 12, y = 12 },
{ id = "ship", type = "ship", x = 360, y = 420 },
{ id = "hive", type = "hive", x = 360, y = 20 },
{ id = "hive1", type = "hive", x = 560, y = 20 }
},
name = "start",
tracks = {}
}
},
rules = {
{
act = {
{ "setText", entity = "readout", text = "\"score \" .. score .. \" lives \" .. lives" }
},
note = "The readout, every frame",
on = "frame"
},
{
a = "shot",
act = {
{ "destroy", entity = "self" },
{ "damage", amount = 1, entity = "other" },
{ "addScore", amount = 10 }
},
b = "rock",
note = "A shot lands on a rock",
on = "collision",
when = {}
},
{
act = {
{ "emit", b = 60, count = 24, entity = "self", g = 120, r = 255 },
{ "addScore", amount = 50 }
},
type = "rock",
note = "A rock dies",
on = "death",
when = {}
},
{
a = "rock",
act = {
{ "destroy", entity = "self" },
{ "damage", amount = 1, entity = "other" }
},
b = "ship",
note = "A rock rams the ship",
on = "collision",
when = {}
},
{
act = {
{ "addVar", amount = -1, name = "lives" },
{ "flash", b = 60, g = 60, r = 255, seconds = 0.3 },
{ "setVar", entity = "self", name = "health", value = "1" },
{ "moveTo", entity = "self", x = 360, y = 420 }
},
type = "ship",
note = "The ship is lost",
on = "death",
when = {}
},
{
act = {
{ "gameOver" }
},
note = "Out of lives",
on = "frame",
when = {
{ "test", expr = "lives <= 0" }
}
},
{
act = {
{ "destroy", entity = "self" }
},
each = "rock",
note = "A rock slips past the bottom",
on = "frame",
when = {
{ "test", expr = "self.y > 500" }
}
}
},
title = "Rocks",
vars = { lives = 3, score = 0 }
}

View file

@ -1,140 +0,0 @@
-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine.
return {
types = {
prompt = {
behaviours = {},
look = { b = 80, g = 220, kind = "text", r = 255 },
vars = {}
},
readout = {
look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" }
},
verdict = {
behaviours = {},
look = { b = 180, g = 255, kind = "text", r = 160 },
vars = {}
}
},
layers = {
{ file = "Forge/kit/clip.mkv", kind = "disc" },
{ kind = "overlay" }
},
rooms = {
{
entities = {
{ id = "readout", type = "readout", x = 12, y = 12 },
{ id = "prompt", type = "prompt", x = 250, y = 60 },
{ id = "verdict", type = "verdict", x = 250, y = 100 }
},
name = "start",
tracks = {}
}
},
rules = {
{
act = {
{ "setText", entity = "readout", text = "\"score \" .. score .. \" lives \" .. lives" }
},
note = "The readout, every frame",
on = "frame"
},
{
act = {
{ "setText", entity = "prompt", text = "\"PRESS!\"" }
},
note = "Window one is open",
on = "frame",
when = {
{ "discBetween", from = 72, to = 120 }
}
},
{
act = {
{ "setVar", name = "hitone", value = true },
{ "addScore", amount = 250 },
{ "setText", entity = "verdict", text = "\"HIT\"" }
},
note = "Window one answered in time",
on = "frame",
when = {
{ "discBetween", from = 72, to = 120 },
{ "keyPressed", key = "SPACE" },
{ "once", tag = "one" }
}
},
{
act = {
{ "addVar", amount = -1, name = "lives" },
{ "setText", entity = "verdict", text = "\"MISS\"" },
{ "setText", entity = "prompt", text = "\"\"" }
},
frame = 120,
note = "Window one closed unanswered",
on = "frameReached",
when = {
{ "test", expr = "not hitone" }
}
},
{
act = {
{ "setText", entity = "prompt", text = "\"UP!\"" }
},
note = "Window two is open",
on = "frame",
when = {
{ "discBetween", from = 150, to = 200 }
}
},
{
act = {
{ "setVar", name = "hittwo", value = true },
{ "addScore", amount = 250 },
{ "setText", entity = "verdict", text = "\"HIT\"" }
},
note = "Window two answered in time",
on = "frame",
when = {
{ "discBetween", from = 150, to = 200 },
{ "keyPressed", key = "UP" },
{ "once", tag = "two" }
}
},
{
act = {
{ "addVar", amount = -1, name = "lives" },
{ "setText", entity = "verdict", text = "\"MISS\"" },
{ "setText", entity = "prompt", text = "\"\"" }
},
frame = 200,
note = "Window two closed unanswered",
on = "frameReached",
when = {
{ "test", expr = "not hittwo" }
}
},
{
act = {
{ "setVar", name = "hitone", value = false },
{ "setVar", name = "hittwo", value = false },
{ "setText", entity = "verdict", text = "\"\"" },
{ "discTo", frame = 1 }
},
frame = 280,
note = "The clip runs out: round again",
on = "frameReached",
when = {}
},
{
act = {
{ "gameOver" }
},
note = "Out of lives",
on = "frame",
when = {
{ "test", expr = "lives <= 0" }
}
}
},
title = "Moments",
vars = { lives = 3, score = 0 }
}

View file

@ -1,115 +0,0 @@
-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine.
return {
types = {
gun = {
behaviours = {
{ ammo = 6, kind = "gun", player = 1, reload = "offscreen" }
},
look = { kind = "none" },
vars = { ammo = 6 }
},
hand = {
behaviours = {
{ kind = "hitbox", track = "hand" }
},
look = { kind = "none" },
vars = {}
},
readout = {
look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" }
},
target = {
behaviours = {
{ kind = "hitbox", track = "target" },
{ kind = "health", max = 1 }
},
look = { kind = "none" },
vars = { health = 1 }
}
},
layers = {
{ file = "Forge/kit/clip.mkv", kind = "disc" },
{ kind = "overlay" }
},
rooms = {
{
entities = {
{ id = "readout", type = "readout", x = 12, y = 12 },
{ id = "gun", type = "gun", x = 30, y = 450 },
{ id = "target", type = "target", x = 450, y = 210 },
{ id = "hand", type = "hand", x = 600, y = 210 }
},
name = "start",
tracks = {
{
boxes = {
{ at = 150, h = 90, w = 90, x = 405, y = 165 },
{ at = 210, h = 90, w = 90, x = 405, y = 165 }
},
key = "frame",
name = "target"
},
{
boxes = {
{ at = 220, h = 105, w = 240, x = 480, y = 157 },
{ at = 260, h = 105, w = 240, x = 480, y = 157 }
},
key = "frame",
name = "hand"
}
}
}
},
rules = {
{
act = {
{ "setText", entity = "readout", text = "\"score \" .. score .. \" ammo \" .. gun.ammo .. \" misses \" .. misses" }
},
note = "The readout, every frame",
on = "frame"
},
{
act = {
{ "addScore", amount = 100 },
{ "damage", amount = 1, entity = "self" },
{ "playSound", file = "Forge/kit/hit.wav" }
},
type = "target",
note = "The target is hit",
on = "hit",
when = {}
},
{
act = {
{ "addScore", amount = -200 },
{ "flash", b = 40, g = 40, r = 255, seconds = 0.3 }
},
type = "hand",
note = "The hand is hit",
on = "hit",
when = {}
},
{
act = {
{ "addVar", amount = 1, name = "misses" },
{ "playSound", file = "Forge/kit/shot.wav" }
},
note = "A shot at nothing",
on = "miss",
when = {
{ "test", expr = "not event.offscreen" }
}
},
{
act = {
{ "say", seconds = 1, text = "\"DRAW!\"" }
},
frame = 150,
note = "Draw!",
on = "frameReached",
when = {}
}
},
title = "Draw",
vars = { misses = 0, score = 0 }
}

View file

@ -1,244 +0,0 @@
-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine.
return {
dialogues = {
guard = {
nodes = {
ok = {
choices = {},
text = "Then go ahead.",
who = "Guard"
},
start = {
choices = {
{ next = "why", text = "Why not?" },
{ text = "Fine." }
},
text = "Nobody passes.",
who = "Guard"
},
why = {
choices = {
{
act = {
{ "setVar", name = "allowed", value = true }
},
next = "ok",
text = "I have a key.",
when = "has(\"key\")"
},
{ text = "I see." }
},
text = "The door is locked, and I have no key.",
who = "Guard"
}
},
start = "start"
}
},
types = {
backdrop = {
behaviours = {},
look = { file = "Forge/kit/yard.png", kind = "sprite" },
vars = {}
},
door = {
behaviours = {
{ kind = "hotspot", name = "door", walkX = 590, walkY = 330 }
},
look = { anchor = "feet", file = "Forge/kit/door.png", kind = "sprite" },
vars = {}
},
guard = {
behaviours = {
{ kind = "hotspot", name = "guard", walkX = 480, walkY = 360 }
},
look = { anchor = "feet", faces = "left", file = "Forge/kit/hero.png", frames = 8, kind = "sprite" },
vars = {}
},
hero = {
behaviours = {
{ kind = "walker", speed = 160 },
{ fps = 10, kind = "frames", states = "idle=1-1, walk=2-5" }
},
look = { anchor = "feet", file = "Forge/kit/hero.png", frames = 8, kind = "sprite" },
vars = {}
},
key = {
behaviours = {
{ kind = "hotspot", name = "key", walkX = 200, walkY = 400 }
},
look = { file = "Forge/kit/key.png", kind = "sprite" },
vars = {}
},
readout = {
look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" }
}
},
layers = {
{ kind = "overlay" }
},
rooms = {
{
depthSort = true,
entities = {
{ id = "readout", type = "readout", x = 12, y = 12 },
{ id = "backdrop", type = "backdrop", x = 360, y = 240 },
{ id = "hero", type = "hero", x = 150, y = 440 },
{ id = "door", type = "door", x = 590, y = 300 },
{ id = "key", type = "key", x = 200, y = 380 },
{ id = "guard", type = "guard", x = 520, y = 340 }
},
name = "yard",
scaleBy = {
{ scale = 0.6, y = 300 },
{ scale = 1.0, y = 460 }
},
tracks = {},
walk = {
{ 60, 320, 660, 320, 700, 460, 40, 460 }
}
},
{
depthSort = true,
entities = {
{ id = "backdrop", type = "backdrop", x = 360, y = 240 },
{ id = "readout", type = "readout", x = 12, y = 12 }
},
name = "hall",
tracks = {},
walk = {
{ 40, 300, 680, 300, 700, 460, 40, 460 }
}
}
},
rules = {
{
act = {
{ "setText", entity = "readout", text = "(sentence or \"\") .. \" score \" .. score" }
},
note = "The readout, every frame",
on = "frame"
},
{
act = {
{ "walkToPointer", entity = "hero", player = 1 }
},
interrupt = true,
note = "A click on the floor walks there",
on = "pressed",
switch = "SWITCH_BUTTON3",
when = {}
},
{
act = {
{ "say", seconds = 1, text = "\"It is a \" .. event.target .. \".\"" }
},
note = "Look at anything",
on = "verb",
verb = "look",
when = {}
},
{
act = {
{ "walkToHotspot", entity = "hero", target = "self" },
{ "give", item = "key" },
{ "addScore", amount = 1 },
{ "destroy", entity = "self" }
},
note = "Take the key",
on = "verb",
target = "key",
verb = "take",
when = {}
},
{
act = {
{ "walkToHotspot", entity = "hero", target = "self" },
{ "face", entity = "hero", target = "self" },
{ "talk", dialogue = "guard" }
},
note = "Talk to the guard",
on = "verb",
target = "guard",
verb = "talk",
when = {}
},
{
act = {
{ "say", seconds = 1, text = "\"The guard shakes his head.\"" }
},
note = "The door, while the guard objects",
on = "verb",
target = "door",
verb = "open",
when = {
{ "test", expr = "not allowed" }
}
},
{
act = {
{ "walkToHotspot", entity = "hero", target = "self" },
{ "playSound", file = "Forge/kit/door.wav" },
{ "fade", out = true, seconds = 0.5 },
{ "goTo", entity = "hero", room = "hall", x = 120, y = 420 },
{ "fade", out = false, seconds = 0.5 },
{ "addScore", amount = 1 }
},
controls = false,
note = "The door opens",
on = "verb",
target = "door",
verb = "open",
when = {
{ "test", expr = "allowed" }
}
},
{
act = {
{ "say", seconds = 1, text = "\"That does not work.\"" }
},
note = "Anything else",
on = "verb",
when = {}
},
{
act = {
{ "setVerb", verb = "look" }
},
key = "L",
note = "The verb keys",
on = "pressed",
when = {}
},
{
act = {
{ "setVerb", verb = "take" }
},
key = "T",
note = "Take",
on = "pressed",
when = {}
},
{
act = {
{ "setVerb", verb = "open" }
},
key = "O",
note = "Open",
on = "pressed",
when = {}
},
{
act = {
{ "setVerb", verb = "talk" }
},
key = "K",
note = "Talk",
on = "pressed",
when = {}
}
},
title = "The Yard",
vars = { allowed = false, score = 0 },
verbs = { "walk", "look", "take", "use", "open", "talk" }
}

View file

@ -1,129 +0,0 @@
-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine.
return {
types = {
camera = {
behaviours = {
{ kind = "camera", lag = 0.2, lookY = 0.6, mode = "follow", target = "hero", x = 0, y = 3, z = 7 }
},
look = { kind = "none" },
vars = {}
},
floor = {
behaviours = {
{ kind = "solid" }
},
look = { b = 90, d = 12, g = 110, h = 0.4, kind = "mesh", r = 90, shape = "box", w = 30 },
vars = {}
},
hero = {
behaviours = {
{ down = "DOWN", kind = "keys", left = "LEFT", right = "RIGHT", up = "UP" },
{ height = 0.9, jump = 6.5, kind = "character", radius = 0.3, speed = 4 },
{ idle = "Survey", kind = "animator", walk = "Walk" }
},
look = { d = 0.6, file = "Forge/kit/fox.glb", h = 0.9, kind = "model", scale = 0.008, w = 0.6 },
vars = {}
},
ledge = {
behaviours = {
{ kind = "solid" }
},
look = { b = 70, d = 4, g = 90, h = 0.4, kind = "mesh", r = 110, shape = "box", w = 4 },
vars = {}
},
prize = {
behaviours = {
{ kind = "trigger" }
},
look = { b = 60, g = 200, kind = "mesh", r = 255, shape = "sphere", w = 0.6 },
vars = {}
},
readout = {
look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" }
}
},
layers = {
{ fov = 55, kind = "scene3d" },
{ kind = "overlay" }
},
rooms = {
{
entities = {
{ id = "readout", type = "readout", x = 12, y = 12 },
{ id = "floor", type = "floor", x = 0.0, y = -0.2, z = 0.0 },
{ id = "ledge", type = "ledge", x = 6.0, y = 1.2, z = 0.0 },
{ id = "prize", type = "prize", x = 6.0, y = 2.0, z = 0.0 },
{ id = "hero", type = "hero", x = -4.0, y = 0.5, z = 0.0 },
{ id = "camera", type = "camera", x = -4.0, y = 3.0, z = 7.0 }
},
name = "start",
tracks = {}
}
},
rules = {
{
act = {
{ "setText", entity = "readout", text = "\"score \" .. score" }
},
note = "The readout, every frame",
on = "frame"
},
{
act = {
{ "setState", entity = "self", state = "walk" }
},
each = "hero",
note = "Walking is a state the animator plays",
on = "frame",
when = {
{ "test", expr = "self.dx != 0 or self.dy != 0" }
}
},
{
act = {
{ "setState", entity = "self", state = "idle" }
},
each = "hero",
note = "Standing still",
on = "frame",
when = {
{ "test", expr = "self.dx == 0 and self.dy == 0" }
}
},
{
act = {
{ "jump", entity = "hero" }
},
note = "Jump, with ground underfoot",
on = "frame",
when = {
{ "keyPressed", key = "SPACE" },
{ "onGround", entity = "hero" }
}
},
{
a = "prize",
act = {
{ "destroy", entity = "self" },
{ "addScore", amount = 100 },
{ "playSound", file = "Forge/kit/coin.wav" }
},
b = "hero",
note = "The prize is taken",
on = "enter",
when = {}
},
{
act = {
{ "moveTo", entity = "hero", x = -4, y = 0.5, z = 0 }
},
note = "Fallen off the world",
on = "frame",
when = {
{ "test", expr = "hero.y < -5" }
}
}
},
title = "Field",
vars = { score = 0 }
}

View file

@ -1,215 +0,0 @@
-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine.
return {
types = {
camera = {
behaviours = {
{ kind = "camera", mode = "rail", track = "rail" }
},
look = { kind = "none" },
vars = {}
},
floor = {
behaviours = {
{ kind = "solid" }
},
look = { b = 90, d = 60, g = 75, h = 0.2, kind = "mesh", r = 70, shape = "box", w = 40 },
vars = {}
},
gun = {
behaviours = {
{ ammo = 6, kind = "gun", player = 1, reload = "offscreen" }
},
look = { kind = "none" },
vars = { ammo = 6 }
},
painting = {
behaviours = {},
look = { b = 255, d = 0.2, g = 255, h = 16, kind = "mesh", r = 255, shape = "box", texture = "Forge/kit/yard.png", unlit = true, w = 24 },
vars = {}
},
pillar = {
behaviours = {},
look = { d = 1.2, h = 8, kind = "mesh", occluder = true, shape = "box", w = 1.2 },
vars = {}
},
readout = {
look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" }
},
zombie = {
behaviours = {
{ kind = "health", linger = 2, max = 2, ragdoll = true },
{ kind = "seek", speed = 1.6, stopAt = 2.2, target = "camera" },
{ attack = "Run", idle = "Survey", kind = "animator", walk = "Walk" },
{ kind = "target" },
{ after = 1.5, kind = "timer", name = "bite", start = false }
},
look = { d = 1.4, file = "Forge/kit/fox.glb", h = 0.9, kind = "model", scale = 0.01, w = 0.8 },
vars = { health = 2 }
}
},
layers = {
{ fov = 60, kind = "scene3d" },
{ kind = "overlay" }
},
rooms = {
{
entities = {
{ id = "readout", type = "readout", x = 12, y = 12 },
{ id = "floor", type = "floor", x = 0.0, y = -0.1, z = -20.0 },
{ id = "painting", type = "painting", x = 0.0, y = 6.0, z = -30.0 },
{ id = "pillar", type = "pillar", x = 0.0, y = 4.0, z = -22.0 },
{ id = "camera", type = "camera", x = 0.0, y = 1.6, z = 0.0 },
{ id = "gun", type = "gun", x = 0.0, y = 0.0, z = 0.0 }
},
name = "start",
tracks = {
{
key = "time",
name = "rail",
points = {
{
at = 0,
look = { 0, 1.2, -10 },
stop = "nil",
x = 0,
y = 1.6,
z = 0
},
{
at = 4,
look = { 0, 1.2, -16 },
stop = "gate",
x = 0,
y = 1.6,
z = -6
},
{
at = 10,
look = { 2, 1.2, -24 },
stop = "pillar",
x = 2,
y = 1.6,
z = -14
},
{
at = 14,
look = { 2, 1.2, -28 },
stop = "nil",
x = 2,
y = 1.6,
z = -18
}
}
}
}
}
},
rules = {
{
act = {
{ "setText", entity = "readout", text = "\"score \" .. score .. \" health \" .. health .. \" ammo \" .. gun.ammo" }
},
note = "The readout, every frame",
on = "frame"
},
{
act = {
{ "spawn", type = "zombie", x = -2, y = 0, z = -14 },
{ "spawn", type = "zombie", x = 2, y = 0, z = -15 }
},
name = "gate",
note = "The gate: two come out of the dark",
on = "stopped",
when = {}
},
{
act = {
{ "spawn", type = "zombie", x = -1, y = 0, z = -23 },
{ "spawn", type = "zombie", x = 0, y = 0, z = -26 },
{ "spawn", type = "zombie", x = 4, y = 0, z = -24 }
},
name = "pillar",
note = "The pillar: three more, one from behind it",
on = "stopped",
when = {}
},
{
act = {
{ "setState", entity = "self", state = "walk" }
},
type = "zombie",
note = "A zombie walks the moment it is made",
on = "spawn",
when = {}
},
{
act = {
{ "damage", amount = 1, entity = "self" },
{ "addScore", amount = 100 },
{ "playSound", file = "Forge/kit/hit.wav" }
},
type = "zombie",
note = "A shot lands",
on = "hit",
when = {}
},
{
act = {
{ "setState", entity = "self", state = "attack" },
{ "timerStart", after = 1.5, entity = "self", name = "bite" }
},
type = "zombie",
note = "It reaches the camera and rears up",
on = "arrived",
when = {}
},
{
act = {
{ "addVar", amount = -1, name = "health" },
{ "flash", b = 40, g = 40, r = 255, seconds = 0.3 },
{ "shake", amount = 0.3, seconds = 0.4 },
{ "timerStart", after = 1.5, entity = "self", name = "bite" }
},
type = "zombie",
name = "bite",
note = "The bite lands unless it was killed in time",
on = "timer",
when = {
{ "inState", entity = "self", state = "attack" }
}
},
{
act = {
{ "pathNext", entity = "camera" }
},
note = "The stop is clear: move on",
on = "frame",
when = {
{ "waiting", entity = "camera" },
{ "test", expr = "count(\"zombie\") == 0" }
}
},
{
act = {
{ "say", seconds = 2, text = "\"YOU ARE DEAD\"" },
{ "gameOver" }
},
note = "Out of health",
on = "frame",
when = {
{ "test", expr = "health <= 0" },
{ "once", scope = "game", tag = "over" }
}
},
{
act = {
{ "say", seconds = 2, text = "\"STAGE CLEAR\"" }
},
note = "The end of the rail",
on = "railEnd",
when = {}
}
},
title = "The Dead Yard",
vars = { health = 3, score = 0 }
}

View file

@ -1,193 +0,0 @@
-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine.
return {
layers = {
{ gravity = 1500, kind = "world2d" },
{ kind = "bezel" }
},
rooms = {
{
entities = {
{ id = "ground", type = "ground", x = 360, y = 440 },
{ id = "hero", type = "hero", x = 200, y = 420 },
{ id = "readout", type = "readout", x = 12, y = 12 },
{ id = "coin", type = "coin", x = 300, y = 392 },
{ id = "coin1", type = "coin", x = 420, y = 392 },
{ id = "coin2", type = "coin", x = 560, y = 300 },
{ id = "spike", type = "spike", x = 640, y = 420 },
{ id = "door", type = "door", x = 700, y = 420 }
},
name = "start",
tracks = {}
},
{
entities = {
{ id = "ground", type = "ground", x = 360, y = 440 },
{ id = "readout", type = "readout", x = 12, y = 12 },
{ id = "coin", type = "coin", x = 400, y = 392 }
},
name = "cave",
tracks = {}
}
},
rules = {
{
act = {
{ "run", direction = -1, entity = "hero" }
},
note = "Run left",
on = "frame",
when = {
{ "keyHeld", key = "LEFT" }
}
},
{
act = {
{ "run", direction = 1, entity = "hero" }
},
note = "Run right",
on = "frame",
when = {
{ "keyHeld", key = "RIGHT" }
}
},
{
act = {
{ "jump", entity = "hero" },
{ "playSound", file = "Forge/kit/jump.wav" }
},
note = "Jump, with ground underfoot",
on = "frame",
when = {
{ "onGround", entity = "hero" },
{ "keyPressed", key = "SPACE" }
}
},
{
act = {
{ "setText", entity = "readout", text = "\"score \" .. score .. \" lives \" .. lives" }
},
note = "The readout, every frame",
on = "frame"
},
{
a = "coin",
act = {
{ "addScore", amount = 10 },
{ "destroy", entity = "self" },
{ "playSound", file = "Forge/kit/coin.wav" }
},
b = "hero",
note = "Take a coin",
on = "enter",
when = {}
},
{
a = "spike",
act = {
{ "moveTo", entity = "hero", x = 200, y = 380 },
{ "playSound", file = "Forge/kit/hit.wav" }
},
b = "hero",
note = "Spikes hurt",
on = "enter",
when = {}
},
{
a = "door",
act = {
{ "goTo", entity = "hero", room = "cave", x = 60, y = 380 },
{ "addVar", amount = -1, name = "lives" }
},
b = "hero",
note = "Through the door",
on = "enter",
when = {}
},
{
act = {
{ "playMusic", file = "Forge/kit/loop.ogg" }
},
note = "Music",
on = "roomStart",
when = {}
},
{
act = {
{ "submitScore", board = "default" },
{ "say", seconds = 2, text = "\"GAME OVER\"" },
{ "gameOver" }
},
note = "Out of lives",
on = "frame",
when = {
{ "test", expr = "lives <= 0" },
{ "once", scope = "game", tag = "over" }
}
},
{
act = {
{ "credit" }
},
note = "A coin",
on = "pressed",
switch = "SWITCH_COIN1",
when = {}
},
{
act = {
{ "addVar", amount = -1, name = "credits" },
{ "setVar", name = "lives", value = "3" },
{ "restart" }
},
note = "Continue",
on = "pressed",
switch = "SWITCH_START1",
when = {
{ "test", expr = "credits > 0 and lives <= 0" }
}
}
},
title = "Coin Run",
types = {
coin = {
behaviours = {
{ kind = "trigger" },
{ kind = "spin", rate = 180 }
},
look = { file = "Forge/kit/coin.png", kind = "sprite" },
vars = {}
},
door = {
behaviours = {
{ kind = "trigger" }
},
look = { anchor = "feet", file = "Forge/kit/door.png", kind = "sprite" },
vars = {}
},
ground = {
behaviours = {
{ kind = "solid" }
},
look = { b = 90, g = 70, h = 40, kind = "box", r = 60, w = 720 }
},
hero = {
behaviours = {
{ jump = 620, kind = "platformer", speed = 260 },
{ fps = 10, kind = "frames", states = "idle=1-1, walk=2-5, jump=6-6, fall=7-7" }
},
look = { file = "Forge/kit/hero.png", frames = 8, kind = "sprite" },
vars = {}
},
readout = {
look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" }
},
spike = {
behaviours = {
{ kind = "trigger" }
},
look = { anchor = "feet", file = "Forge/kit/spike.png", kind = "sprite" },
vars = {}
}
},
vars = { credits = 0, lives = 3, score = 0 }
}

View file

@ -1,704 +0,0 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program 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; either version 3
* of the License, or (at your option) any later version.
*
* This program 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. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- Singe 2.xx Features -------------------------------------------------------
-- SINGE_FRAMEWORK_VERSION, SWITCH_*, FONT_QUALITY_*, MODE_*, MOUSE_*, OVERLAY_*,
-- RENDER_*, DISC_*, SOUND_ERROR_* and the SINGE_* input code layout are all
-- pushed by the engine before this file runs, so they are defined in exactly
-- one place: singe.c.
if singeGetScriptPath ~= nil then
DIR = singeGetScriptPath():match("(.*[/\\])") or "./"
end
function utilDeepCopy(original)
local copy
if type(original) == 'table' then
copy = {}
for key, value in next, original, nil do
copy[utilDeepCopy(key)] = utilDeepCopy(value)
end
setmetatable(copy, utilDeepCopy(getmetatable(original)))
else -- number, string, boolean, etc
copy = original
end
return copy
end
function utilDump(o)
if type(o) == 'table' then
local s = '{ '
for k,v in pairs(o) do
if type(k) ~= 'number' then
k = '"'..k..'"'
end
s = s .. '['..k..'] = ' .. utilDump(v) .. ','
end
return s .. '} '
else
return tostring(o)
end
end
function utilGetTableSize(t)
local count = 0
for _ in pairs(t) do
count = count + 1
end
return count
end
-- Returns a copy of a { NAME = { name = NAME, value = N } } table with every value shifted.
function utilOffsetTable(source, offset)
local copy = utilDeepCopy(source)
for _, entry in pairs(copy) do
entry.value = entry.value + offset
end
return copy
end
function utilTrim(s)
return (s:gsub("^%s*(.-)%s*$", "%1"))
end
SCANCODE = {
A = { name = "A", value = 4 },
B = { name = "B", value = 5 },
C = { name = "C", value = 6 },
D = { name = "D", value = 7 },
E = { name = "E", value = 8 },
F = { name = "F", value = 9 },
G = { name = "G", value = 10 },
H = { name = "H", value = 11 },
I = { name = "I", value = 12 },
J = { name = "J", value = 13 },
K = { name = "K", value = 14 },
L = { name = "L", value = 15 },
M = { name = "M", value = 16 },
N = { name = "N", value = 17 },
O = { name = "O", value = 18 },
P = { name = "P", value = 19 },
Q = { name = "Q", value = 20 },
R = { name = "R", value = 21 },
S = { name = "S", value = 22 },
T = { name = "T", value = 23 },
U = { name = "U", value = 24 },
V = { name = "V", value = 25 },
W = { name = "W", value = 26 },
X = { name = "X", value = 27 },
Y = { name = "Y", value = 28 },
Z = { name = "Z", value = 29 },
MAIN_1 = { name = "MAIN_1", value = 30 },
MAIN_2 = { name = "MAIN_2", value = 31 },
MAIN_3 = { name = "MAIN_3", value = 32 },
MAIN_4 = { name = "MAIN_4", value = 33 },
MAIN_5 = { name = "MAIN_5", value = 34 },
MAIN_6 = { name = "MAIN_6", value = 35 },
MAIN_7 = { name = "MAIN_7", value = 36 },
MAIN_8 = { name = "MAIN_8", value = 37 },
MAIN_9 = { name = "MAIN_9", value = 38 },
MAIN_0 = { name = "MAIN_0", value = 39 },
RETURN = { name = "RETURN", value = 40 },
ESCAPE = { name = "ESCAPE", value = 41 },
BACKSPACE = { name = "BACKSPACE", value = 42 },
TAB = { name = "TAB", value = 43 },
SPACE = { name = "SPACE", value = 44 },
MINUS = { name = "MINUS", value = 45 },
EQUALS = { name = "EQUALS", value = 46 },
LEFTBRACKET = { name = "LEFTBRACKET", value = 47 },
RIGHTBRACKET = { name = "RIGHTBRACKET", value = 48 },
BACKSLASH = { name = "BACKSLASH", value = 49 },
NONUSHASH = { name = "NONUSHASH", value = 50 },
SEMICOLON = { name = "SEMICOLON", value = 51 },
APOSTROPHE = { name = "APOSTROPHE", value = 52 },
GRAVE = { name = "GRAVE", value = 53 },
COMMA = { name = "COMMA", value = 54 },
PERIOD = { name = "PERIOD", value = 55 },
SLASH = { name = "SLASH", value = 56 },
CAPSLOCK = { name = "CAPSLOCK", value = 57 },
F1 = { name = "F1", value = 58 },
F2 = { name = "F2", value = 59 },
F3 = { name = "F3", value = 60 },
F4 = { name = "F4", value = 61 },
F5 = { name = "F5", value = 62 },
F6 = { name = "F6", value = 63 },
F7 = { name = "F7", value = 64 },
F8 = { name = "F8", value = 65 },
F9 = { name = "F9", value = 66 },
F10 = { name = "F10", value = 67 },
F11 = { name = "F11", value = 68 },
F12 = { name = "F12", value = 69 },
PRINTSCREEN = { name = "PRINTSCREEN", value = 70 },
SCROLLLOCK = { name = "SCROLLLOCK", value = 71 },
PAUSE = { name = "PAUSE", value = 72 },
INSERT = { name = "INSERT", value = 73 },
HOME = { name = "HOME", value = 74 },
PAGEUP = { name = "PAGEUP", value = 75 },
DELETE = { name = "DELETE", value = 76 },
END = { name = "END", value = 77 },
PAGEDOWN = { name = "PAGEDOWN", value = 78 },
RIGHT = { name = "RIGHT", value = 79 },
LEFT = { name = "LEFT", value = 80 },
DOWN = { name = "DOWN", value = 81 },
UP = { name = "UP", value = 82 },
NUMLOCKCLEAR = { name = "NUMLOCKCLEAR", value = 83 },
KP_DIVIDE = { name = "KP_DIVIDE", value = 84 },
KP_MULTIPLY = { name = "KP_MULTIPLY", value = 85 },
KP_MINUS = { name = "KP_MINUS", value = 86 },
KP_PLUS = { name = "KP_PLUS", value = 87 },
KP_ENTER = { name = "KP_ENTER", value = 88 },
KP_1 = { name = "KP_1", value = 89 },
KP_2 = { name = "KP_2", value = 90 },
KP_3 = { name = "KP_3", value = 91 },
KP_4 = { name = "KP_4", value = 92 },
KP_5 = { name = "KP_5", value = 93 },
KP_6 = { name = "KP_6", value = 94 },
KP_7 = { name = "KP_7", value = 95 },
KP_8 = { name = "KP_8", value = 96 },
KP_9 = { name = "KP_9", value = 97 },
KP_0 = { name = "KP_0", value = 98 },
KP_PERIOD = { name = "KP_PERIOD", value = 99 },
NONUSBACKSLASH = { name = "NONUSBACKSLASH", value = 100 },
APPLICATION = { name = "APPLICATION", value = 101 },
POWER = { name = "POWER", value = 102 },
KP_EQUALS = { name = "KP_EQUALS", value = 103 },
F13 = { name = "F13", value = 104 },
F14 = { name = "F14", value = 105 },
F15 = { name = "F15", value = 106 },
F16 = { name = "F16", value = 107 },
F17 = { name = "F17", value = 108 },
F18 = { name = "F18", value = 109 },
F19 = { name = "F19", value = 110 },
F20 = { name = "F20", value = 111 },
F21 = { name = "F21", value = 112 },
F22 = { name = "F22", value = 113 },
F23 = { name = "F23", value = 114 },
F24 = { name = "F24", value = 115 },
EXECUTE = { name = "EXECUTE", value = 116 },
HELP = { name = "HELP", value = 117 },
MENU = { name = "MENU", value = 118 },
SELECT = { name = "SELECT", value = 119 },
STOP = { name = "STOP", value = 120 },
AGAIN = { name = "AGAIN", value = 121 },
UNDO = { name = "UNDO", value = 122 },
CUT = { name = "CUT", value = 123 },
COPY = { name = "COPY", value = 124 },
PASTE = { name = "PASTE", value = 125 },
FIND = { name = "FIND", value = 126 },
MUTE = { name = "MUTE", value = 127 },
VOLUMEUP = { name = "VOLUMEUP", value = 128 },
VOLUMEDOWN = { name = "VOLUMEDOWN", value = 129 },
KP_COMMA = { name = "KP_COMMA", value = 133 },
KP_EQUALSAS400 = { name = "KP_EQUALSAS400", value = 134 },
INTERNATIONAL1 = { name = "INTERNATIONAL1", value = 135 },
INTERNATIONAL2 = { name = "INTERNATIONAL2", value = 136 },
INTERNATIONAL3 = { name = "INTERNATIONAL3", value = 137 },
INTERNATIONAL4 = { name = "INTERNATIONAL4", value = 138 },
INTERNATIONAL5 = { name = "INTERNATIONAL5", value = 139 },
INTERNATIONAL6 = { name = "INTERNATIONAL6", value = 140 },
INTERNATIONAL7 = { name = "INTERNATIONAL7", value = 141 },
INTERNATIONAL8 = { name = "INTERNATIONAL8", value = 142 },
INTERNATIONAL9 = { name = "INTERNATIONAL9", value = 143 },
LANG1 = { name = "LANG1", value = 144 },
LANG2 = { name = "LANG2", value = 145 },
LANG3 = { name = "LANG3", value = 146 },
LANG4 = { name = "LANG4", value = 147 },
LANG5 = { name = "LANG5", value = 148 },
LANG6 = { name = "LANG6", value = 149 },
LANG7 = { name = "LANG7", value = 150 },
LANG8 = { name = "LANG8", value = 151 },
LANG9 = { name = "LANG9", value = 152 },
ALTERASE = { name = "ALTERASE", value = 153 },
SYSREQ = { name = "SYSREQ", value = 154 },
CANCEL = { name = "CANCEL", value = 155 },
CLEAR = { name = "CLEAR", value = 156 },
PRIOR = { name = "PRIOR", value = 157 },
RETURN2 = { name = "RETURN2", value = 158 },
SEPARATOR = { name = "SEPARATOR", value = 159 },
OUT = { name = "OUT", value = 160 },
OPER = { name = "OPER", value = 161 },
CLEARAGAIN = { name = "CLEARAGAIN", value = 162 },
CRSEL = { name = "CRSEL", value = 163 },
EXSEL = { name = "EXSEL", value = 164 },
KP_00 = { name = "KP_00", value = 176 },
KP_000 = { name = "KP_000", value = 177 },
THOUSANDSSEPARATOR = { name = "THOUSANDSSEPARATOR", value = 178 },
DECIMALSEPARATOR = { name = "DECIMALSEPARATOR", value = 179 },
CURRENCYUNIT = { name = "CURRENCYUNIT", value = 180 },
CURRENCYSUBUNIT = { name = "CURRENCYSUBUNIT", value = 181 },
KP_LEFTPAREN = { name = "KP_LEFTPAREN", value = 182 },
KP_RIGHTPAREN = { name = "KP_RIGHTPAREN", value = 183 },
KP_LEFTBRACE = { name = "KP_LEFTBRACE", value = 184 },
KP_RIGHTBRACE = { name = "KP_RIGHTBRACE", value = 185 },
KP_TAB = { name = "KP_TAB", value = 186 },
KP_BACKSPACE = { name = "KP_BACKSPACE", value = 187 },
KP_A = { name = "KP_A", value = 188 },
KP_B = { name = "KP_B", value = 189 },
KP_C = { name = "KP_C", value = 190 },
KP_D = { name = "KP_D", value = 191 },
KP_E = { name = "KP_E", value = 192 },
KP_F = { name = "KP_F", value = 193 },
KP_XOR = { name = "KP_XOR", value = 194 },
KP_POWER = { name = "KP_POWER", value = 195 },
KP_PERCENT = { name = "KP_PERCENT", value = 196 },
KP_LESS = { name = "KP_LESS", value = 197 },
KP_GREATER = { name = "KP_GREATER", value = 198 },
KP_AMPERSAND = { name = "KP_AMPERSAND", value = 199 },
KP_DBLAMPERSAND = { name = "KP_DBLAMPERSAND", value = 200 },
KP_VERTICALBAR = { name = "KP_VERTICALBAR", value = 201 },
KP_DBLVERTICALBAR = { name = "KP_DBLVERTICALBAR", value = 202 },
KP_COLON = { name = "KP_COLON", value = 203 },
KP_HASH = { name = "KP_HASH", value = 204 },
KP_SPACE = { name = "KP_SPACE", value = 205 },
KP_AT = { name = "KP_AT", value = 206 },
KP_EXCLAM = { name = "KP_EXCLAM", value = 207 },
KP_MEMSTORE = { name = "KP_MEMSTORE", value = 208 },
KP_MEMRECALL = { name = "KP_MEMRECALL", value = 209 },
KP_MEMCLEAR = { name = "KP_MEMCLEAR", value = 210 },
KP_MEMADD = { name = "KP_MEMADD", value = 211 },
KP_MEMSUBTRACT = { name = "KP_MEMSUBTRACT", value = 212 },
KP_MEMMULTIPLY = { name = "KP_MEMMULTIPLY", value = 213 },
KP_MEMDIVIDE = { name = "KP_MEMDIVIDE", value = 214 },
KP_PLUSMINUS = { name = "KP_PLUSMINUS", value = 215 },
KP_CLEAR = { name = "KP_CLEAR", value = 216 },
KP_CLEARENTRY = { name = "KP_CLEARENTRY", value = 217 },
KP_BINARY = { name = "KP_BINARY", value = 218 },
KP_OCTAL = { name = "KP_OCTAL", value = 219 },
KP_DECIMAL = { name = "KP_DECIMAL", value = 220 },
KP_HEXADECIMAL = { name = "KP_HEXADECIMAL", value = 221 },
LCTRL = { name = "LCTRL", value = 224 },
LSHIFT = { name = "LSHIFT", value = 225 },
LALT = { name = "LALT", value = 226 },
LGUI = { name = "LGUI", value = 227 },
RCTRL = { name = "RCTRL", value = 228 },
RSHIFT = { name = "RSHIFT", value = 229 },
RALT = { name = "RALT", value = 230 },
RGUI = { name = "RGUI", value = 231 },
MODE = { name = "MODE", value = 257 },
AUDIONEXT = { name = "AUDIONEXT", value = 258 },
AUDIOPREV = { name = "AUDIOPREV", value = 259 },
AUDIOSTOP = { name = "AUDIOSTOP", value = 260 },
AUDIOPLAY = { name = "AUDIOPLAY", value = 261 },
AUDIOMUTE = { name = "AUDIOMUTE", value = 262 },
MEDIASELECT = { name = "MEDIASELECT", value = 263 },
WWW = { name = "WWW", value = 264 },
MAIL = { name = "MAIL", value = 265 },
CALCULATOR = { name = "CALCULATOR", value = 266 },
COMPUTER = { name = "COMPUTER", value = 267 },
AC_SEARCH = { name = "AC_SEARCH", value = 268 },
AC_HOME = { name = "AC_HOME", value = 269 },
AC_BACK = { name = "AC_BACK", value = 270 },
AC_FORWARD = { name = "AC_FORWARD", value = 271 },
AC_STOP = { name = "AC_STOP", value = 272 },
AC_REFRESH = { name = "AC_REFRESH", value = 273 },
AC_BOOKMARKS = { name = "AC_BOOKMARKS", value = 274 },
BRIGHTNESSDOWN = { name = "BRIGHTNESSDOWN", value = 275 },
BRIGHTNESSUP = { name = "BRIGHTNESSUP", value = 276 },
DISPLAYSWITCH = { name = "DISPLAYSWITCH", value = 277 },
KBDILLUMTOGGLE = { name = "KBDILLUMTOGGLE", value = 278 },
KBDILLUMDOWN = { name = "KBDILLUMDOWN", value = 279 },
KBDILLUMUP = { name = "KBDILLUMUP", value = 280 },
EJECT = { name = "EJECT", value = 281 },
SLEEP = { name = "SLEEP", value = 282 },
APP1 = { name = "APP1", value = 283 },
APP2 = { name = "APP2", value = 284 },
AUDIOREWIND = { name = "AUDIOREWIND", value = 285 },
AUDIOFASTFORWARD = { name = "AUDIOFASTFORWARD", value = 286 }
}
MODIFIER = {
NONE = { name = "NONE", value = 0x0000 },
LSHIFT = { name = "LSHIFT", value = 0x0001 },
RSHIFT = { name = "RSHIFT", value = 0x0002 },
LCTRL = { name = "LCTRL", value = 0x0040 },
RCTRL = { name = "RCTRL", value = 0x0080 },
LALT = { name = "LALT", value = 0x0100 },
RALT = { name = "RALT", value = 0x0200 },
LGUI = { name = "LGUI", value = 0x0400 },
RGUI = { name = "RGUI", value = 0x0800 },
NUM = { name = "NUM", value = 0x1000 },
CAPS = { name = "CAPS", value = 0x2000 },
MODE = { name = "MODE", value = 0x4000 },
SCROLL = { name = "SCROLL", value = 0x8000 },
SHIFT = { name = "SHIFT", value = 0x0001 + 0x0002 },
CTRL = { name = "CTRL", value = 0x0040 + 0x0080 },
ALT = { name = "ALT", value = 0x0100 + 0x0200 },
GUI = { name = "GUI", value = 0x0400 + 0x0800 }
}
-- Controller codes are built from the engine's layout: SINGE_GAMEPAD_BASE plus
-- SINGE_GAMEPAD_STRIDE per controller; each axis takes SINGE_AXIS_STRIDE codes
-- (axis, negative direction, positive direction) and buttons follow at
-- SINGE_GAMEPAD_BUTTON_OFFSET in SDL's button order.
local function gamepadAxisCodes(axis, name, negative, positive)
local base = SINGE_GAMEPAD_BASE + axis * SINGE_AXIS_STRIDE
return {
{ name, base }, { name .. "_" .. negative, base + 1 }, { name .. "_" .. positive, base + 2 }
}
end
local gamepadCodes = {}
for _, entry in ipairs(gamepadAxisCodes(0, "AXIS_LEFT_X", "L", "R")) do gamepadCodes[#gamepadCodes + 1] = entry end
for _, entry in ipairs(gamepadAxisCodes(1, "AXIS_LEFT_Y", "U", "D")) do gamepadCodes[#gamepadCodes + 1] = entry end
for _, entry in ipairs(gamepadAxisCodes(2, "AXIS_RIGHT_X", "L", "R")) do gamepadCodes[#gamepadCodes + 1] = entry end
for _, entry in ipairs(gamepadAxisCodes(3, "AXIS_RIGHT_Y", "U", "D")) do gamepadCodes[#gamepadCodes + 1] = entry end
for _, entry in ipairs(gamepadAxisCodes(4, "AXIS_LEFT_TRIGGER", "N", "P")) do gamepadCodes[#gamepadCodes + 1] = entry end
for _, entry in ipairs(gamepadAxisCodes(5, "AXIS_RIGHT_TRIGGER", "N", "P")) do gamepadCodes[#gamepadCodes + 1] = entry end
local gamepadButtons = {
"BUTTON_A", "BUTTON_B", "BUTTON_X", "BUTTON_Y", "BUTTON_BACK", "BUTTON_GUIDE", "BUTTON_START",
"BUTTON_LEFT_STICK", "BUTTON_RIGHT_STICK", "BUTTON_LEFT_BUMPER", "BUTTON_RIGHT_BUMPER",
"DPAD_UP", "DPAD_DOWN", "DPAD_LEFT", "DPAD_RIGHT"
}
for index, name in ipairs(gamepadButtons) do
gamepadCodes[#gamepadCodes + 1] = { name, SINGE_GAMEPAD_BASE + SINGE_GAMEPAD_BUTTON_OFFSET + index - 1 }
end
GAMEPAD_0 = {}
for _, entry in ipairs(gamepadCodes) do
GAMEPAD_0[entry[1]] = { name = entry[1], value = entry[2] }
end
GAMEPAD_1 = utilOffsetTable(GAMEPAD_0, SINGE_GAMEPAD_STRIDE)
GAMEPAD_2 = utilOffsetTable(GAMEPAD_0, SINGE_GAMEPAD_STRIDE * 2)
GAMEPAD_3 = utilOffsetTable(GAMEPAD_0, SINGE_GAMEPAD_STRIDE * 3)
GAMEPAD_AXIS_LEFT_X = 0
GAMEPAD_AXIS_LEFT_Y = 1
GAMEPAD_AXIS_RIGHT_X = 2
GAMEPAD_AXIS_RIGHT_Y = 3
GAMEPAD_AXIS_LEFT_TRIGGER = 4
GAMEPAD_AXIS_RIGHT_TRIGGER = 5
-- Mouse codes: SINGE_MOUSE_BASE plus SINGE_MOUSE_STRIDE per mouse; buttons then wheel.
local mouseNames = { "BUTTON_LEFT", "BUTTON_RIGHT", "BUTTON_MIDDLE", "BUTTON_X1", "BUTTON_X2", "WHEEL_UP", "WHEEL_DOWN" }
MOUSE_0 = {}
for index, name in ipairs(mouseNames) do
MOUSE_0[name] = { name = name, value = SINGE_MOUSE_BASE + index - 1 }
end
MOUSE_1 = utilOffsetTable(MOUSE_0, SINGE_MOUSE_STRIDE)
MOUSE_2 = utilOffsetTable(MOUSE_0, SINGE_MOUSE_STRIDE * 2)
MOUSE_3 = utilOffsetTable(MOUSE_0, SINGE_MOUSE_STRIDE * 3)
-- Older names for engine functions that gained a setter.
if mouseSetEnabled ~= nil then
mouseEnable = function() mouseSetEnabled(true) end
mouseDisable = function() mouseSetEnabled(false) end
end
if singeSetPauseKeyEnabled ~= nil then
singeEnablePauseKey = function() singeSetPauseKeyEnabled(true) end
singeDisablePauseKey = function() singeSetPauseKeyEnabled(false) end
end
-- Hypseus Singe's name for the quit key setting, with the opposite sense: catching the
-- key means the engine stops quitting on it.
if singeSetQuitKeyEnabled ~= nil then
keyboardCatchQuit = function(catch) singeSetQuitKeyEnabled(not catch) end
end
-- Hypseus Singe Compatibility ------------------------------------------------
-- Names Hypseus Singe registers in its own engine and Singe answers from here.
-- Every one is guarded, so an engine that grows a real implementation wins.
-- Hypseus extension. Chooses an overlay resolution and hands it to
-- overlaySetResolution, which is all Hypseus's own call does: 1 is the disc's
-- video size, 3 is 360x240, 4 is the size given (ignored without one, as in
-- Hypseus) and anything else, 2 included, is half the disc on both axes. Singe's
-- discGetWidth and discGetHeight report the canvas size when there is no disc,
-- which is the space a disc-less game draws into, so no special case is needed.
local OVERLAY_PRESET_FULL = 1
local OVERLAY_PRESET_NONSQUARE = 3
local OVERLAY_PRESET_CUSTOM = 4
local OVERLAY_NONSQUARE_WIDTH = 360
local OVERLAY_NONSQUARE_HEIGHT = 240
local OVERLAY_HALF = 2
if setOverlaySize == nil and overlaySetResolution ~= nil then
function setOverlaySize(preset, width, height)
if preset == OVERLAY_PRESET_FULL then
overlaySetResolution(discGetWidth(), discGetHeight())
elseif preset == OVERLAY_PRESET_NONSQUARE then
overlaySetResolution(OVERLAY_NONSQUARE_WIDTH, OVERLAY_NONSQUARE_HEIGHT)
elseif preset == OVERLAY_PRESET_CUSTOM then
if width ~= nil and height ~= nil then
overlaySetResolution(width, height)
end
else
overlaySetResolution(discGetWidth() // OVERLAY_HALF, discGetHeight() // OVERLAY_HALF)
end
end
end
-- Hypseus extension. Hypseus registers one C function under two names and Singe
-- already has the other one, so this is a pure alias of overlaySetResolution.
if setOverlayResolution == nil and overlaySetResolution ~= nil then
setOverlayResolution = overlaySetResolution
end
-- Hypseus extension. True in Hypseus while it is rewriting a game's asset paths
-- for an -espath install. Singe's virtual file system reaches a game's files
-- wherever they were installed, so there is nothing to rewrite and false is the
-- right answer: the games that ask keep the MYDIR they already have.
if rewriteStatus == nil then
function rewriteStatus() return false end
end
-- Singe 3.00 moved the sprite handle to the first argument. Games written for
-- 2.10 can set SINGE_LEGACY_SPRITE_ARGS = true (or LEGACY_SPRITE_ARGS = true in
-- games.dat) to keep calling the old way.
if SINGE_LEGACY_SPRITE_ARGS and spriteDraw ~= nil and not SINGE_LEGACY_SPRITE_WRAPPED then
-- Once only: a game may load this file more than once, and a second wrap would undo the first.
SINGE_LEGACY_SPRITE_WRAPPED = true
local newSpriteDraw = spriteDraw
spriteDraw = function(...)
local args = { ... }
local id = table.remove(args)
return newSpriteDraw(id, table.unpack(args))
end
local newSpriteLoop = spriteLoop
spriteLoop = function(loop, id) return newSpriteLoop(id, loop) end
local newSpriteQuality = spriteQuality
spriteQuality = function(smooth, id) return newSpriteQuality(id, smooth) end
local newSpriteRotate = spriteRotate
spriteRotate = function(angle, id) return newSpriteRotate(id, angle) end
local newSpriteRotateAndScale = spriteRotateAndScale
spriteRotateAndScale = function(...)
local args = { ... }
local id = table.remove(args)
return newSpriteRotateAndScale(id, table.unpack(args))
end
local newSpriteScale = spriteScale
spriteScale = function(...)
local args = { ... }
local id = table.remove(args)
return newSpriteScale(id, table.unpack(args))
end
local newSpriteSetFrame = spriteSetFrame
spriteSetFrame = function(frame, id) return newSpriteSetFrame(id, frame) end
-- Hypseus extensions ship in the old order too: (x, y, frame, spr),
-- (x, y, scale, frame, spr) or (x, y, scaleX, scaleY, frame, spr).
local newSpriteDrawFrame = spriteDrawFrame
spriteDrawFrame = function(...)
local args = { ... }
local id = table.remove(args)
local frame = table.remove(args)
return newSpriteDrawFrame(id, args[1], args[2], frame, table.unpack(args, 3, #args))
end
local newSpriteResetColorKey = spriteResetColorKey
spriteResetColorKey = function(enabled, id) return newSpriteResetColorKey(id, enabled) end
local newSpriteRotateFrame = spriteRotateFrame
spriteRotateFrame = function(degrees, frame, id) return newSpriteRotateFrame(id, degrees, frame) end
local newSpriteDrawRotatedFrame = spriteDrawRotatedFrame
spriteDrawRotatedFrame = function(...)
local args = { ... }
local id = table.remove(args)
return newSpriteDrawRotatedFrame(id, table.unpack(args))
end
-- spriteDrawGrid is the one Hypseus writes with the handle in the middle,
-- (x, y, spr, rectX, rectY, width, height), not on the end.
local newSpriteDrawGrid = spriteDrawGrid
spriteDrawGrid = function(x, y, id, sourceX, sourceY, width, height)
return newSpriteDrawGrid(id, x, y, sourceX, sourceY, width, height)
end
end
-- Hypseus names for calls Singe already has, and nothing more: each is a plain
-- alias, so it takes exactly the arguments the Singe call takes. They are set
-- after the legacy wrapping above, which means the six spriteAnim ones follow
-- SINGE_LEGACY_SPRITE_ARGS with the rest of the sprite family -- old order when
-- a game asked for it, handle first when it did not -- and a game never has two
-- argument orders in play at once.
if hypseusGetHeight == nil and singeGetHeight ~= nil then
hypseusGetHeight = singeGetHeight
end
if hypseusGetWidth == nil and singeGetWidth ~= nil then
hypseusGetWidth = singeGetWidth
end
if takeScreenshot == nil and singeScreenshot ~= nil then
takeScreenshot = singeScreenshot
end
-- Hypseus counts real mice apart from the ones it derives from a gamepad; Singe
-- has no derived mice, so mouseHowMany is already the real count.
if mouseHowManyReal == nil and mouseHowMany ~= nil then
mouseHowManyReal = mouseHowMany
end
if spriteAnimGetFrame == nil and spriteGetFrame ~= nil then
spriteAnimGetFrame = spriteGetFrame
end
if spriteAnimIsPlaying == nil and spriteIsPlaying ~= nil then
spriteAnimIsPlaying = spriteIsPlaying
end
if spriteAnimLoop == nil and spriteLoop ~= nil then
spriteAnimLoop = spriteLoop
end
if spriteAnimPause == nil and spritePause ~= nil then
spriteAnimPause = spritePause
end
if spriteAnimPlay == nil and spritePlay ~= nil then
spriteAnimPlay = spritePlay
end
if spriteAnimSetFrame == nil and spriteSetFrame ~= nil then
spriteAnimSetFrame = spriteSetFrame
end
if videoGetLanguageDescription ~= nil then
discGetLanguageDescription = videoGetLanguageDescription
end
-- Singe 1.xx Features -------------------------------------------------------
-- Make old random number calls still work
random = {}
random.new = math.random
-- Map old Daphne calls to Singe calls
if singeGetHeight ~= nil then
daphneGetHeight = singeGetHeight
daphneGetWidth = singeGetWidth
daphneScreenshot = singeScreenshot
end
-- Singe 2.10 Threaded Application Support ------------------------------------
if singeMain ~= nil then
local singeThread = coroutine.create(singeMain)
onOverlayUpdate = function()
local ok, err = coroutine.resume(singeThread)
if not ok then
error(err, 0)
end
if coroutine.status(singeThread) == "dead" then
singeQuit()
end
return OVERLAY_UPDATED
end
singeYield = coroutine.yield
end
-- ===== Online high scores =====
--
-- A thin layer over Singe/Master.singe so a game says scoreSubmit(1000) and nothing else. The
-- account, the server address and the player's name belong to the machine, not to the game, and
-- were set up once in the menu's Online Account tool.
--
-- Nothing here waits on the network. A score is written to a queue on disk and sent when there is
-- a connection, because a cabinet is often offline and a game must never stall on the wire to show
-- its own board. scoreUpdate() has to run once a frame for any of it to progress.
local masterLoaded = false
local function scoreReady()
if not masterLoaded then
-- Loaded on first use rather than at startup: a game that never posts a score should not
-- pay for the module or open the file.
dofile("Singe/Master.singe")
masterLoaded = true
end
return masterSignedIn()
end
-- The id a game is known by, from its games.dat entry. A game without one can be played but not
-- ranked: there is nothing stable to key a board on, and inventing one here would make a different
-- board every time the file moved.
function scoreGameId()
return singeGetGameId()
end
-- Say that a play is starting. Optional, and worth calling: the service then times the play by its
-- own clock, which is the one thing in a submission that has not been through this machine. A
-- cabinet with no connection simply records no time.
function scoreBegin()
local id = scoreGameId()
if not id or not scoreReady() then
return false
end
masterPlayStart(id, function() end)
return true
end
-- Queue a score. Returns false when the cabinet has no account or the game has no id, so a game
-- can decide whether to say anything about it.
function scoreSubmit(value, board, meta)
local id = scoreGameId()
if not id or not scoreReady() then
return false
end
masterSubmitScore(id, value, board, meta)
return true
end
-- The board, as a callback: onDone(ok, result) where result.top is the list and result.standing is
-- this player's place.
function scoreBoard(onDone, board)
local id = scoreGameId()
if not id or not scoreReady() then
onDone(false, "this cabinet is not signed in")
return false
end
masterBoard(id, board, onDone)
return true
end
function scorePlayerName()
return scoreReady() and MASTER.name or nil
end
function scoreWaiting()
return masterLoaded and masterQueueLength() or 0
end
-- Once a frame, from onOverlayUpdate. Safe to call in a game that never posts anything.
function scoreUpdate()
if masterLoaded then
masterPump()
end
end

BIN
assets/FreeSansBold.ttf (Stored with Git LFS)

Binary file not shown.

View file

@ -1,349 +0,0 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program 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; either version 3
* of the License, or (at your option) any later version.
*
* This program 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. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- The master service, from the cabinet's side.
--
-- One place that knows the server address, the signed-in account and the player's name, so the menu
-- and every game agree about them. It lives in the data root rather than a game's own directory
-- because an account belongs to the machine: a game posting a score needs the same token the menu
-- signed in with.
--
-- Everything here is asynchronous -- see Singe/Net.singe for why -- so every call takes a function
-- and calls it back. masterPump() must run once a frame for any of it to make progress.
dofile("Singe/Net.singe")
local cjson = require("cjson")
MASTER_DEFAULT_URL = "https://master.singeengine.com"
MASTER = {
url = MASTER_DEFAULT_URL,
token = nil,
email = nil,
name = nil,
queue = {}, -- scores waiting for a connection
}
local settingsPath = nil
local flushing = false
local lastFlush = 0
local flushDue = false
local FLUSH_EVERY = 30000
local function path()
if not settingsPath then
settingsPath = singeGetSystemInfo().dataRoot .. "master.dat"
end
return settingsPath
end
function masterLoad()
local file = io.open(path(), "r")
if not file then
return
end
local text = file:read("*a")
file:close()
local ok, stored = pcall(cjson.decode, text)
if ok and type(stored) == "table" then
MASTER.url = stored.url or MASTER_DEFAULT_URL
MASTER.token = stored.token
MASTER.email = stored.email
MASTER.name = stored.name
MASTER.queue = stored.queue or {}
end
end
function masterSave()
local file = io.open(path(), "w")
if not file then
return false
end
file:write(cjson.encode({ url = MASTER.url, token = MASTER.token, email = MASTER.email,
name = MASTER.name, queue = MASTER.queue }))
file:close()
return true
end
function masterSignedIn()
return MASTER.token ~= nil
end
-- Every call goes through here so the token, the content type and the JSON decoding are in one
-- place, and so a 401 always means the same thing: whatever we were holding is no longer a
-- session, and pretending otherwise only produces a second failure later.
local function request(method, endpoint, body, onDone, opts)
opts = opts or {}
local headers = { ["Content-Type"] = "application/json" }
if MASTER.token and not opts.anonymous then
headers["Authorization"] = "Bearer " .. MASTER.token
end
netRequest({
url = MASTER.url .. endpoint, method = method, headers = headers,
body = body and cjson.encode(body) or nil,
toFile = opts.toFile, onProgress = opts.onProgress,
}, function(ok, result)
if not ok then
return onDone(false, result.error)
end
local parsed = {}
if result.body and result.body ~= "" then
local decoded, value = pcall(cjson.decode, result.body)
if decoded and type(value) == "table" then
parsed = value
end
end
if result.status == 401 and not opts.anonymous then
MASTER.token = nil
masterSave()
return onDone(false, "signed out by the server", parsed, result)
end
if result.status >= 400 then
return onDone(false, parsed.message or ("the server said " .. result.status), parsed, result)
end
onDone(true, parsed, parsed, result)
end)
end
MASTER_REQUEST = request
function masterSignIn(email, password, onDone)
request("POST", "/v1/account/login", { email = email, password = password }, function(ok, reply)
if not ok then
return onDone(false, reply)
end
MASTER.token = reply.token
MASTER.email = reply.email
masterSave()
-- The display name is not part of signing in, so it is fetched once and kept: a game
-- about to post a score should not have to discover it has none mid-play.
masterWhoAmI(function() end)
onDone(true, reply)
end, { anonymous = true })
end
function masterRegister(email, password, onDone)
request("POST", "/v1/account/register", { email = email, password = password }, onDone, { anonymous = true })
end
function masterRecover(email, onDone)
request("POST", "/v1/account/recover", { email = email }, onDone, { anonymous = true })
end
function masterSignOut(onDone)
local had = MASTER.token
MASTER.token = nil
MASTER.email = nil
MASTER.name = nil
masterSave()
if had then
-- Told after the fact: the cabinet is signed out either way, and a server that cannot be
-- reached must not leave somebody looking signed in.
MASTER.token = had
request("POST", "/v1/account/logout", nil, function() end)
MASTER.token = nil
end
if onDone then onDone(true) end
end
function masterWhoAmI(onDone)
request("GET", "/v1/account", nil, function(ok, reply)
if ok then
MASTER.email = reply.email or MASTER.email
masterSave()
end
onDone(ok, reply)
end)
end
function masterSetName(name, onDone)
request("POST", "/v1/player/name", { name = name }, function(ok, reply)
if ok then
MASTER.name = reply.name
masterSave()
end
onDone(ok, reply)
end)
end
function masterCatalogue(onDone)
request("GET", "/v1/catalogue", nil, function(ok, reply)
if ok then
for _, game in ipairs(reply.games or {}) do
game.version = game.version and math.floor(game.version) or nil
game.installed = game.installed and math.floor(game.installed) or nil
game.size = game.size and math.floor(game.size) or nil
end
end
onDone(ok, ok and reply.games or reply)
end)
end
-- Download a game to a file and check it against the digest the catalogue published. A download
-- that arrives wrong is worse than one that fails: it installs, and then does not run.
function masterDownload(gameId, version, toFile, expectSha, onProgress, onDone)
-- JSON has one number type and Lua 5.4 has two, so a version that went out as 1 comes back as
-- 1.0 and would build a path ending "/1.0" that the service does not recognise.
local path = string.format("/v1/download/%s/%d", gameId, math.floor(version))
request("GET", path, nil, function(ok, reply, _, result)
if not ok then
return onDone(false, reply)
end
local file = io.open(toFile, "rb")
if not file then
return onDone(false, "the download did not land on disk")
end
local bytes = file:read("*a")
file:close()
if expectSha and utilSha256(bytes) ~= expectSha then
os.remove(toFile)
return onDone(false, "the download did not match its published checksum")
end
onDone(true, { bytes = #bytes, file = toFile })
end, { toFile = toFile, onProgress = onProgress })
end
function masterTellInstalled(gameId, version, onDone)
request("POST", "/v1/installed", { game = gameId, version = version and math.floor(version) or nil },
onDone or function() end)
end
function masterBoard(gameId, board, onDone)
request("GET", "/v1/scores/" .. gameId .. (board and ("?board=" .. board) or ""), nil, function(ok, reply)
-- JSON has one number type and Lua 5.4 has two, so a score that went out as 4242 comes back
-- as 4242.0 and would be shown to a player with a decimal point on it.
if ok then
for _, row in ipairs(reply.top or {}) do
row.value = math.floor(row.value)
row.at = row.at and math.floor(row.at) or nil
end
if reply.standing then
reply.standing.value = math.floor(reply.standing.value)
reply.standing.rank = math.floor(reply.standing.rank)
reply.standing.players = math.floor(reply.standing.players)
end
end
onDone(ok, reply)
end)
end
-- A score is queued first and sent afterwards. A cabinet is often offline, and a game must never
-- wait on the wire to show its own board; the queue is written to disk so a power cut between the
-- play and the send does not lose it.
-- Tell the service a play is starting. What comes back travels with the score, so the server can
-- say how long the play took by its own clock; a cabinet that is offline simply has no play id and
-- the score carries no time, which is honest rather than broken.
function masterPlayStart(gameId, onDone)
if not masterSignedIn() then
MASTER.play = nil
if onDone then onDone(false, "not signed in") end
return
end
request("POST", "/v1/play/start", { game = gameId }, function(ok, reply)
MASTER.play = ok and reply.play and math.floor(reply.play) or nil
if onDone then onDone(ok, reply) end
end)
end
function masterSubmitScore(gameId, value, board, meta)
MASTER.queue[#MASTER.queue + 1] = { game = gameId, value = math.floor(value),
board = board or "default", meta = meta,
play = MASTER.play }
masterSave()
-- Send it on the next pump rather than waiting out the retry interval: a player who has just
-- finished wants to see themselves on the board, and the interval exists for retrying what
-- failed, not for delaying what has not been tried. This is a flag rather than a timestamp:
-- singeGetTicks() counts from engine start, so "lastFlush = 0" would mean "once the engine has
-- been up FLUSH_EVERY", which early in a run is never.
flushDue = true
end
function masterQueueLength()
return #MASTER.queue
end
-- Hand the queue to the server one at a time. A refusal that is the score's own fault (the game is
-- unknown, the value is nonsense) drops it, because retrying it forever would block everything
-- behind it; anything else leaves it in place to try again.
local function flushQueue()
if flushing or #MASTER.queue == 0 or not MASTER.token then
return
end
flushing = true
local entry = MASTER.queue[1]
request("POST", "/v1/scores/" .. entry.game, { value = entry.value, board = entry.board, meta = entry.meta, play = entry.play },
function(ok, reply, parsed, result)
local status = result and result.status or 0
if ok or (status >= 400 and status < 500 and status ~= 429) then
table.remove(MASTER.queue, 1)
masterSave()
-- A backlog should drain, not trickle out one per interval.
if #MASTER.queue > 0 then
flushDue = true
end
end
flushing = false
end)
end
function masterPump()
netPump()
if flushDue or (singeGetTicks() - lastFlush > FLUSH_EVERY) then
flushDue = false
lastFlush = singeGetTicks()
flushQueue()
end
end
masterLoad()

View file

@ -1,41 +0,0 @@
<rml>
<!--
Singe 3
Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
The bundled game menu: the document Singe/Menu.singe fills and drives. Every region is placed
by the script (it owns the geometry so the attract video, drawn into the overlay underneath,
lands exactly in the #video hole); Singe/menu.rcss only says what the regions look like.
Licensed under the GNU General Public License, version 3 or later.
-->
<head>
<title>Menu</title>
<link type="text/rcss" href="Singe/gui.rcss"/>
<link type="text/rcss" href="Singe/menu.rcss"/>
</head>
<body id="menu">
<div id="games">
<div id="list" class="list"></div>
<div id="poster" class="art"></div>
<div id="marquee" class="art"></div>
<div id="video"></div>
<div id="info" class="panel">
<div id="title"></div>
<div id="credits" class="line"></div>
<div id="developer" class="line"></div>
<div id="publisher" class="line"></div>
<div id="port" class="line"></div>
<div id="description"></div>
</div>
<div id="footer">
<div id="hints">Up / Down: select &nbsp; Left / Right: page &nbsp; PgUp / PgDn: details &nbsp; Start or button: play &nbsp; Service: tools</div>
</div>
</div>
<!-- Every service tool draws through this one page: Singe/Tools.singe hands the menu a list of
rows and Singe/MenuDocument.singe turns them into the divs below it. -->
<div id="tool" class="tool hidden">
<h1 id="toolTitle"></h1>
<div id="toolBody"></div>
</div>
</body>
</rml>

View file

@ -1,404 +0,0 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program 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; either version 3
* of the License, or (at your option) any later version.
*
* This program 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. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- The bundled game menu. Everything the menu *is* lives here: the game list read from every
-- games.dat, the selection and how it moves, the attract video, launching a game and coming back,
-- and the service tools. Nothing here draws.
--
-- Drawing is a renderer, chosen once at startup by whether this machine has a GPU device:
--
-- Singe/MenuDocument.singe an RmlUi document, which needs the GPU device the 3D scene uses
-- Singe/MenuOverlay.singe sprites and fontPrint straight into the overlay, which needs nothing
--
-- The two draw the same menu and take the same keys. A renderer provides the MENU_RENDER table;
-- see the top of either file for what is in it.
dofile("Singe/Framework.singe")
dofile("Singe/Master.singe")
dofile("Singe/Tools.singe")
local lfs = require("lfs")
function cleanTitle(a)
local output = string.lower(a)
output = string.gsub(output, '%p', ' ')
output = string.gsub(output, '^ +', '')
output = string.gsub(output, '^the ', '')
output = string.gsub(output, '^a ', '')
output = string.gsub(output, '^an ', '')
return output
end
function compareTitles(a, b)
return cleanTitle(a.TITLE) < cleanTitle(b.TITLE)
end
-- Makes a game the selection, wrapping at either end of the list, and shows it.
function gameSelect(index)
if index < 1 then
index = GAME_COUNT
elseif index > GAME_COUNT then
index = 1
end
unloadGameAssets()
GAME_SELECTED = index
loadGameAssets()
end
-- Starts the selected game. The menu comes back from the top when it ends.
function gameStart()
SHUTDOWN_FROM_PUSH = true
scriptPush(GAME_LIST[GAME_SELECTED])
end
function loadGameAssets()
local game = GAME_LIST[GAME_SELECTED]
-- An entry without an attract clip, or naming one that is not there, shows its art and no
-- video rather than taking the whole menu down with a videoLoad that cannot open it.
if menuFileExists(game.ATTRACT) then
VIDEO_ATTRACT = videoLoad(game.ATTRACT)
if game.AUDIO_TRACK then
videoSetAudioTrack(VIDEO_ATTRACT, game.AUDIO_TRACK)
end
videoPlay(VIDEO_ATTRACT)
videoSeek(VIDEO_ATTRACT, game.ATTRACT_START or 0)
else
debugPrint("Menu: " .. tostring(game.TITLE) .. " has no attract clip to play.")
end
MENU_RENDER.showGame(game)
end
-- Where a game's art should be when it is not there. Extracted with the other support files, so
-- it is always present whatever a game does or does not ship.
MENU_MISSING_ART = "Singe/missing.png"
-- Whether a name is a file the engine can open. Asked of the index rather than of the file:
-- io.open, which this used to use, has to hand Lua a real file it can read with the C library, so
-- every picture it was asked about was unpacked out of the .game into data/<game>/cache first --
-- and the menu asks about two of them for every game in the library, none of which it then opens
-- that way: the sprite loader and the document's texture loader both read straight out of the
-- database. lfs.attributes goes through the same virtual filesystem and answers from the index,
-- opening nothing.
function menuFileExists(file)
local path = tostring(file or "")
local found = (path ~= "") and lfs.attributes(path) or nil
return (found ~= nil) and (found.mode ~= "directory")
end
-- A readable art path: the game's own when it can be opened, the missing-art picture when not.
function menuArtPath(file)
local path = tostring(file or "")
if not menuFileExists(path) then
if path ~= "" then
debugPrint("Menu: cannot read " .. path .. "; showing the missing-art picture.")
end
return MENU_MISSING_ART
end
return path
end
-- How far left and right move. A page is a screenful, which is what the renderer answers, but in a
-- library smaller than a screenful every press then lands on the first game or the last and nothing
-- in between can be reached that way at all. A third of the library is never more than three
-- presses from one end to the other, whatever size it is.
function pageStep()
return math.max(1, math.min(MENU_RENDER.pageSize(), math.floor(GAME_COUNT / 3)))
end
-- The game page takes input once the backdrop has finished the intro and settled on the grid.
function menuActive()
return (not toolsActive()) and MENU_RENDER.backdropReady()
end
-- In MODE_FULL every key arrives here as its keysym rather than as a switch, which is what the
-- account tool's typing needs; the switches it still cares about are the pad's, which keep working.
function onKeyPressed(keysym, scancode)
if toolsActive() then
toolsTyped(keysym)
end
end
function onInputPressed(what)
-- The service tools take every switch while they are up, including the one that closes them.
if toolsActive() then
toolsInput(what)
return
end
-- Are we displaying the grid background?
if menuActive() then
if what == SWITCH_SERVICE then
toolsBegin()
elseif what == SWITCH_UP then
gameSelect(GAME_SELECTED - 1)
elseif what == SWITCH_DOWN then
gameSelect(GAME_SELECTED + 1)
elseif what == SWITCH_LEFT then
-- A page back, wrapping to the end from the first game.
if GAME_SELECTED == 1 then
gameSelect(GAME_COUNT)
else
gameSelect(math.max(1, GAME_SELECTED - pageStep()))
end
elseif what == SWITCH_RIGHT then
if GAME_SELECTED == GAME_COUNT then
gameSelect(1)
else
gameSelect(math.min(GAME_COUNT, GAME_SELECTED + pageStep()))
end
elseif what == SWITCH_START1 or what == SWITCH_START2 or what == SWITCH_BUTTON1 or what == SWITCH_BUTTON2 or what == SWITCH_BUTTON3 or what == SWITCH_BUTTON4 then
gameStart()
end
end
end
function onOverlayUpdate()
-- The intro, then the grid behind the menu, and the overlay cleared ready to be drawn into.
MENU_RENDER.backdrop()
-- The network never blocks the frame; it makes progress here instead.
masterPump()
if toolsActive() then
toolsUpdate()
MENU_RENDER.tools()
return(OVERLAY_UPDATED)
end
-- Are we displaying the grid background?
if menuActive() then
-- The page keys take the details over from the walk they do by themselves; the walk picks
-- up from wherever they leave it once its pause is up. They arrive as a scancode rather
-- than a switch because they are not one: a cabinet has no page keys and loses nothing.
local key = keyboardGetLastDown()
if key == SCANCODE.PAGEDOWN.value then
MENU_RENDER.scrollDetails(1)
elseif key == SCANCODE.PAGEUP.value then
MENU_RENDER.scrollDetails(-1)
end
-- The attract clip runs between the two frames the game's entry names.
if VIDEO_ATTRACT and GAME_LIST[GAME_SELECTED].ATTRACT_END then
if videoGetFrame(VIDEO_ATTRACT) > GAME_LIST[GAME_SELECTED].ATTRACT_END then
videoSeek(VIDEO_ATTRACT, GAME_LIST[GAME_SELECTED].ATTRACT_START or 0)
end
end
MENU_RENDER.frame()
end
return(OVERLAY_UPDATED)
end
function onShutdown()
-- With no games found there is no renderer and nothing was loaded; quitting is all there is.
if MENU_RENDER == nil then
return
end
unloadGameAssets()
saveConfig(not SHUTDOWN_FROM_PUSH)
MENU_RENDER.finish()
if SND_INTRO_VOICE ~= SOUND_ERROR_INVALID then
-- A game started before the intro's sound had finished; it wants the speakers to itself.
soundStop(SND_INTRO_VOICE)
end
if SND_INTRO then
soundUnload(SND_INTRO)
end
if SND_CLICK then
soundUnload(SND_CLICK)
end
end
function saveConfig(showIntro)
if GAME_COUNT > 0 then
-- Save what game we're currently viewing
local cfg = io.open(CONFIG_FILE, "w")
if cfg then
cfg:write("GAME_SELECTED = " .. GAME_SELECTED .. "\n")
cfg:write("SHOW_INTRO = " .. tostring(showIntro) .. "\n")
-- The file is written whole, so a renderer forced here has to be written back or it
-- would last exactly one run.
if MENU_OVERLAY then
cfg:write("MENU_OVERLAY = true\n")
end
cfg:close()
else
debugPrint("Unable to write " .. CONFIG_FILE)
end
end
end
function unloadGameAssets()
-- Nothing is loaded when there are no games.
if VIDEO_ATTRACT then
videoUnload(VIDEO_ATTRACT)
VIDEO_ATTRACT = nil
end
MENU_RENDER.hideGame()
end
-- Adds the entries of one games.dat. container is the game database the file came from, or nil.
local function loadGamesDat(source, container)
GAMES = {}
local ok, err = pcall(dofile, source)
if not ok then
debugPrint(source .. ": " .. tostring(err) .. " Skipped.")
GAMES = {}
return
end
for _, value in pairs(GAMES or {}) do
if container then
-- Packed game: the engine opens its files through the database, and so does the menu.
value.CONTAINER = container
for _, key in ipairs({ "CABINET", "MARQUEE", "ATTRACT" }) do
if value[key] and value[key]:sub(1, 6):lower() ~= "singe/" then
value[key] = container .. "/" .. value[key]
end
end
end
table.insert(GAME_LIST, value)
GAME_COUNT = GAME_COUNT + 1
end
GAMES = {}
end
-- Search for games.dat files in subdirectories and inside .game databases.
--
-- The extension is tested before the mode, and that order matters: the engine presents a packed
-- game through the VFS, so lfs.attributes reports a .game as a *directory*, not a file. Testing
-- the mode first sent every container down the loose-directory branch, which loads its games.dat
-- with no container -- and then every CABINET, MARQUEE and ATTRACT path in it named a file that
-- exists only inside the database. A packed library could not show a single piece of its art.
GAME_LIST = {}
GAME_COUNT = 0
for dir in lfs.dir(".") do
if dir ~= "." and dir ~= ".." then
local dirattr = lfs.attributes(dir)
if dir:sub(-5):lower() == ".game" then
loadGamesDat(dir .. "/games.dat", dir)
elseif dirattr and (dirattr.mode == "directory") then
for file in lfs.dir(dir .. "/.") do
if file == "games.dat" then
loadGamesDat(dir .. "/games.dat", nil)
end
end
end
end
end
table.sort(GAME_LIST, compareTitles)
if GAME_COUNT == 0 then
debugPrint("No games found! Exiting.")
singeQuit()
else
overlaySetResolution(discGetWidth(), discGetHeight())
-- Three frames of Singe/menuBackground.mkv: where the menu may take the screen, where the
-- grid section it loops begins, and its last frame. The first two are not the same moment --
-- the menu comes up while the music is still fading out behind it, and only then does the
-- picture start repeating. All three come from util/renderMenuVideo.py, which prints them
-- when it makes the video; the grid section is a whole number of turns of the grid, so
-- playing it over and over has no seam in it. A renderer that draws its own grid uses none
-- of them: it knows where its own logo went.
DISC_MENU_FRAME = 222
DISC_GRID_START = 300
DISC_LAST_FRAME = 419
SHUTDOWN_FROM_PUSH = false
-- The service tools share this click with the audio delay tool.
SND_CLICK = soundLoad("Singe/click.wav")
-- The intro's sound. It belongs to the menu rather than to either renderer: one draws the
-- intro and the other plays a recording of it, and both want the same sound over the top at
-- the same moment. Keeping it out of the video is also what stops it being stored twice.
SND_INTRO = soundLoad("Singe/menuIntro.flac")
SND_INTRO_VOICE = SOUND_ERROR_INVALID
-- Load configuration
SHOW_INTRO = true
CONFIG_FILE = singeGetDataPath() .. "menu.dat"
local confattr = lfs.attributes(CONFIG_FILE)
if confattr then
dofile(CONFIG_FILE)
-- A damaged menu.dat must not take the menu down with it.
GAME_SELECTED = tonumber(GAME_SELECTED) or 1
if GAME_SELECTED > GAME_COUNT then
GAME_SELECTED = GAME_COUNT
end
else
GAME_SELECTED = 1
end
-- The renderer. A GUI document is drawn through the GPU device the 3D scene uses, so without
-- one there is no document to build and guiNew would end the script rather than answer false;
-- the overlay renderer needs nothing but the overlay every game already draws into. Set
-- MENU_OVERLAY = true in menu.dat to use it on a machine that has a GPU, which is how the
-- overlay path gets tested.
if singeHasGpu() and not MENU_OVERLAY then
dofile("Singe/MenuDocument.singe")
else
dofile("Singe/MenuOverlay.singe")
end
MENU_RENDER.begin()
MENU_RENDER.layout()
MENU_RENDER.buildList()
MENU_RENDER.backdropBegin(SHOW_INTRO)
if SHOW_INTRO then
SND_INTRO_VOICE = soundPlay(SND_INTRO)
end
-- Prime the pump
gameSelect(GAME_SELECTED)
end

View file

@ -1,406 +0,0 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program 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; either version 3
* of the License, or (at your option) any later version.
*
* This program 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. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- The menu drawn as an RmlUi document: Singe/Menu.rml styled by Singe/menu.rcss, composited over
-- the overlay, with the attract video drawn into the overlay underneath the hole the document
-- leaves for it. This needs the GPU device the 3D scene uses; Singe/MenuOverlay.singe is the one
-- for a machine without one. Singe/Menu.singe loads exactly one of the two and calls it through
-- MENU_RENDER:
--
-- begin() build what drawing needs
-- finish() give it back
-- backdropBegin(showIntro) start the background
-- backdrop() advance it, and clear the overlay ready to be drawn into
-- backdropReady() the intro is over and the menu may take input
-- layout() place the regions, from the overlay size
-- buildList() the whole game list is known
-- showGame(game) this is the selection now
-- hideGame() let go of the last selection
-- pageSize() how many rows left and right should move by
-- frame() draw the game page
-- tools() draw the service tools page
-- Layout, in overlay pixels: the columns' widths and the space around them; everything else
-- follows from the overlay size in layout(). The marquee and video keep the overlay menu's
-- 320x75 and 320x200 proportions.
dofile("Singe/Backdrop.singe")
-- Layout constants follow.
local MARGIN = 10
local GAP = 8
local LIST_W = 200
local POSTER_W = 180
local FOOTER_H = 24
local MARQUEE_ASPECT_W = 320
local MARQUEE_ASPECT_H = 75
local VIDEO_ASPECT_W = 320
local VIDEO_ASPECT_H = 200
local videoX, videoY, videoW, videoH = 0, 0, 0, 0
local scrollPending = false
local detailScroll = 0 -- How far down the description has walked, in pixels,
local detailDown = true -- and which way it is walking.
local detailClock = 0
local SCROLL_HOLD = 3.0 -- Seconds it rests at each end before moving, as in the overlay
local SCROLL_STEP = 1.1 -- renderer, and seconds a step while it is moving.
local SCROLL_LINE = 16 -- How far a step goes: a line of the description.
function menuElement(id)
return rmlui.contexts["gui" .. GUI].documents["menu"]:GetElementById(id)
end
-- Text on its way into the document, with RML's special characters made harmless.
function menuEscape(text)
local output = tostring(text or "")
output = string.gsub(output, "&", "&amp;")
output = string.gsub(output, "<", "&lt;")
output = string.gsub(output, ">", "&gt;")
return output
end
-- Fits a game's picture inside one of the art regions. The document lives in Singe/, so a game's
-- own file is reached from there through "../"; the engine's files are named as they are.
--
-- Art that is not there shows as art that is not there, which menuArtPath decides -- a games.dat
-- naming a file that was never packed, or a path with a typo in it, is otherwise a silent hole in
-- the page, and it used to be worse than silent: an empty path took the whole engine down through
-- the texture loader. MENU_MISSING_ART is an engine file, so the "singe/" test below leaves it
-- alone.
local function menuArt(id, file)
local path = menuArtPath(file)
if path:sub(1, 6):lower() ~= "singe/" then
path = "../" .. path
end
menuElement(id).style.decorator = 'image("' .. path .. '" contain)'
end
-- One "Label: value" fragment of a details line.
local function menuLabel(label, value)
return '<span class="label">' .. label .. ':</span> ' .. menuEscape(value) .. ' &nbsp; '
end
local function menuPlace(id, x, y, w, h)
local element = menuElement(id)
element:SetClass("placed", true)
element.style.left = x .. "px"
element.style.top = y .. "px"
element.style.width = w .. "px"
element.style.height = h .. "px"
end
local function menuRow(index)
return menuElement("game" .. index)
end
-- A click on a row selects its game; a click on the selected row starts it.
local function menuRowClick(gui, document, id, event, value)
local index = tonumber(id:sub(5))
if not menuActive() then
return
end
if index == GAME_SELECTED then
gameStart()
else
gameSelect(index)
end
end
-- Brings the selected row into view once the document has been laid out.
local function menuScrollToSelection()
local list = menuElement("list")
local row = menuRow(GAME_SELECTED)
local top = row.offset_top - list.scroll_top
if list.client_height <= 0 then
return
end
if top < 0 then
row:ScrollIntoView(true)
elseif top + row.offset_height > list.client_height then
row:ScrollIntoView(false)
end
scrollPending = false
end
-- A tool lines its columns up with runs of spaces, which RML would collapse to one, so they are
-- made non-breaking on the way in. Only the tools do this: a game's description is meant to wrap.
local function menuSpaces(text)
return (string.gsub(menuEscape(text), " +", function(run) return string.rep("&nbsp;", #run) end))
end
-- One row of a service tool, as RML. The kinds are the ones Singe/Tools.singe builds.
local function toolRow(row)
if row.kind == "pair" then
return string.format("<div class='pair'><span class='label'>%s</span><span class='detail'>%s</span></div>",
menuEscape(row.label), menuSpaces(row.value))
elseif row.kind == "sub" then
return "<div class='sub'>" .. menuSpaces(row.text) .. "</div>"
elseif row.kind == "keys" then
return "<p class='keys'>" .. menuSpaces(row.text) .. "</p>"
elseif row.kind == "item" then
return string.format("<div class='%s'>%s</div>", row.selected and "item selected" or "item", menuSpaces(row.text))
elseif row.kind == "blank" then
return "<div class='blank'></div>"
end
return "<div class='row'>" .. menuSpaces(row.text) .. "</div>"
end
MENU_RENDER = {}
MENU_RENDER.begin = function()
-- The document: the size of the overlay, composited over it every frame, and first in line
-- for the mouse and the switches so its rows can be clicked.
GUI = guiNew(overlayGetWidth(), overlayGetHeight())
DOCUMENT = guiLoad(GUI, "Singe/Menu.rml")
guiSetInput(GUI, true)
end
MENU_RENDER.finish = function()
if GUI then
guiDelete(GUI)
GUI = nil
end
backdropFinish()
sceneEnable(false)
end
-- The backdrop is its own file, because util/renderMenuVideo.py draws the same thing to record it.
MENU_RENDER.backdropBegin = function(showIntro)
backdropBegin(showIntro)
end
MENU_RENDER.backdrop = function()
-- Transparent, so the scene behind the overlay is what shows.
colorBackground(0, 0, 0, 0)
overlayClear()
backdropFrame()
end
MENU_RENDER.backdropReady = function()
-- The menu takes the screen once the logo has left, not when a video frame number passes.
return backdropReady()
end
MENU_RENDER.layout = function()
local width = overlayGetWidth()
local height = overlayGetHeight()
local sideX = MARGIN + LIST_W + GAP + POSTER_W + GAP
local sideW = width - MARGIN - sideX
local listH = height - MARGIN * 2 - FOOTER_H - GAP
local posterX = MARGIN + LIST_W + GAP
local footerY = height - MARGIN - FOOTER_H
local marqueeH = math.floor(sideW * MARQUEE_ASPECT_H / MARQUEE_ASPECT_W)
local infoY = 0
videoX = sideX
videoY = MARGIN + marqueeH + GAP
videoW = sideW
videoH = math.floor(sideW * VIDEO_ASPECT_H / VIDEO_ASPECT_W)
infoY = videoY + videoH + GAP
menuPlace("list", MARGIN, MARGIN, LIST_W, listH)
menuPlace("poster", posterX, MARGIN, POSTER_W, videoY + videoH - MARGIN)
menuPlace("marquee", sideX, MARGIN, sideW, marqueeH)
menuPlace("video", videoX, videoY, videoW, videoH)
menuPlace("info", posterX, infoY, width - MARGIN - posterX, MARGIN + listH - infoY)
menuPlace("footer", MARGIN, footerY, width - MARGIN * 2, FOOTER_H)
end
-- Fills the game list with one row per game and routes their clicks here.
MENU_RENDER.buildList = function()
local rows = {}
for index, game in ipairs(GAME_LIST) do
rows[index] = '<div id="game' .. index .. '">' .. menuEscape(game.TITLE) .. '</div>'
end
menuElement("list").inner_rml = table.concat(rows)
for index = 1, GAME_COUNT do
guiSetHandler(GUI, DOCUMENT, "game" .. index, "click", menuRowClick)
end
end
MENU_RENDER.showGame = function(game)
menuRow(GAME_SELECTED):SetClass("selected", true)
scrollPending = true
menuArt("poster", game.CABINET)
menuArt("marquee", game.MARQUEE)
guiSetValue(GUI, DOCUMENT, "title", menuEscape(game.TITLE))
guiSetValue(GUI, DOCUMENT, "credits", menuLabel("Year", game.YEAR) .. menuLabel("Genre", game.GENRE) .. menuLabel("Platform", game.PLATFORM))
guiSetValue(GUI, DOCUMENT, "developer", menuLabel("Developer", game.DEVELOPER))
guiSetValue(GUI, DOCUMENT, "publisher", menuLabel("Publisher", game.PUBLISHER))
guiSetValue(GUI, DOCUMENT, "port", menuLabel("Singe port", game.CREATOR) .. menuLabel("Source", game.SOURCE))
guiSetValue(GUI, DOCUMENT, "description", menuEscape(game.DESCRIPTION))
menuElement("description").scroll_top = 0
detailScroll = 0
detailDown = true
detailClock = 0
end
MENU_RENDER.hideGame = function()
-- The document keeps nothing of its own per game; only the highlight has to come off.
if GAME_SELECTED then
menuRow(GAME_SELECTED):SetClass("selected", false)
end
end
-- Rows that fit in the list at once: what left and right page by.
MENU_RENDER.pageSize = function()
local list = menuElement("list")
local row = menuRow(GAME_SELECTED)
if row.offset_height <= 0 then
return 1
end
return math.max(1, math.floor(list.client_height / row.offset_height))
end
-- The description walks by itself when there is more of it than fits -- down to the end, a rest,
-- and back up to the start -- the same way the overlay renderer's details do. The style sheet
-- gives this box a scroll bar, but nothing drives one from a joystick and up and down belong to the
-- game list, so a long description was simply unreadable past its first few lines.
local function menuScrollDetails()
local element = menuElement("description")
local room = element.scroll_height - element.client_height
if room <= 0 then
return
end
-- The clock starts when there is something to scroll and the menu is on screen, not when the
-- game was selected: the menu is behind the intro for the first seven seconds.
if detailClock == 0 then
detailClock = singeGetTicks()
end
local resting = (detailScroll <= 0) or (detailScroll >= room)
if (singeGetTicks() - detailClock) >= ((resting and SCROLL_HOLD or SCROLL_STEP) * 1000) then
if detailDown and (detailScroll >= room) then
detailDown = false
elseif not detailDown and (detailScroll <= 0) then
detailDown = true
end
detailScroll = math.max(math.min(detailScroll + (detailDown and SCROLL_LINE or -SCROLL_LINE), room), 0)
detailClock = singeGetTicks()
element.scroll_top = detailScroll
end
end
-- Page up and page down, by as much as is on show less a line to read across. The walk resumes
-- from where this leaves it, in the direction it was sent, after its usual rest.
MENU_RENDER.scrollDetails = function(direction)
local element = menuElement("description")
local room = element.scroll_height - element.client_height
if room > 0 then
local page = math.max(element.client_height - SCROLL_LINE, SCROLL_LINE)
detailScroll = math.max(math.min(detailScroll + direction * page, room), 0)
detailDown = (direction > 0)
detailClock = singeGetTicks()
element.scroll_top = detailScroll
end
end
MENU_RENDER.frame = function()
-- Attract Mode Video, under the hole the document leaves for it
if VIDEO_ATTRACT then
videoDraw(VIDEO_ATTRACT, videoX, videoY, videoX + videoW, videoY + videoH)
end
if scrollPending then
menuScrollToSelection()
end
menuScrollDetails()
guiDraw(GUI)
end
MENU_RENDER.tools = function()
local rows = {}
-- The tools redraw only when they say something changed; the document keeps what it was given.
if TOOL_DIRTY then
TOOL_DIRTY = false
-- The same window the overlay renderer draws, so the two show the same page rather than one
-- of them quietly showing more. The style sheet can scroll this box, but nothing drives a
-- scroll bar from a joystick: windowing is what actually keeps the chosen row on screen.
local visible, more, below = toolsVisible(TOOL_ROWS, toolsCapacity())
if more then
rows[#rows + 1] = "<div class='sub'>...</div>"
end
local seenItem = false
for _, row in ipairs(visible) do
-- The marker belongs under the last item rather than under the key hints below it.
if below and seenItem and row.kind ~= "item" then
rows[#rows + 1] = "<div class='sub'>...</div>"
below = false
end
if row.kind == "item" then
seenItem = true
end
rows[#rows + 1] = toolRow(row)
end
if below then
rows[#rows + 1] = "<div class='sub'>...</div>"
end
guiSetValue(GUI, DOCUMENT, "toolTitle", menuEscape(TOOL_TITLE))
guiSetValue(GUI, DOCUMENT, "toolBody", table.concat(rows))
menuElement("games"):SetClass("hidden", toolsActive())
menuElement("tool"):SetClass("hidden", not toolsActive())
menuElement("tool"):SetClass("clear", TOOL_CLEAR)
menuElement("tool"):SetClass("flash", TOOL_FLASH)
end
guiDraw(GUI)
end

View file

@ -1,544 +0,0 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program 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; either version 3
* of the License, or (at your option) any later version.
*
* This program 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. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- The menu drawn with sprites and fontPrint straight into the overlay: the look Singe 2 had, and
-- the one that works on a machine whose GPU device never came up, where there is no GUI document to
-- build. Singe/MenuDocument.singe is the other renderer; Singe/Menu.singe loads exactly one of the
-- two and calls it through MENU_RENDER. See the top of MenuDocument.singe for what is in it.
--
-- One game is on screen at a time, as it always was here, so there is no list to page through
-- visually: up and down move one game, left and right move a page, and the line above the details
-- says where in the library the selection is. An entry with more to say than fits walks down by
-- itself a line at a time, rests, and walks back up again: up and down belong to the library, and
-- a cabinet has no key to spare for scrolling a paragraph. Page up and page down override it for
-- whoever does have a keyboard.
local MARGIN_X = 25
local MARGIN_Y = 25
local MARGIN = 10 -- Around the page, and between the regions on it.
local GAP = 8
local HINT_H = 24 -- The line of key hints along the bottom.
local SIDE_FRAC = 0.40 -- How much of the width the marquee and the attract video take.
local MARQUEE_ASPECT_W = 320 -- What the artwork and the clips are authored at; both regions keep
local MARQUEE_ASPECT_H = 75 -- these proportions whatever the overlay's size turns out to be.
local VIDEO_ASPECT_W = 320
local VIDEO_ASPECT_H = 200
local LINE_HEIGHT = 24
local LABEL_W = 150
local SUB_INDENT = 20
local PAGE_ROWS = 10
local WRAP_BREAK = " [!wb!] "
local HINTS = "Up / Down: select Left / Right: page PgUp / PgDn: details Start or button: play Service: tools"
local font = nil
local hintFont = nil -- Smaller: the hints are a footnote, and at the body's size the line
-- of them ran off the end of a 720 pixel overlay.
local introDone = false
local videoX, videoY, videoW, videoH = 0, 0, 0, 0
local marqueeX, marqueeY, marqueeW, marqueeH = 0, 0, 0, 0
local textX, textY, textW, textH = 0, 0, 0, 0
local hintsX, hintsY = 0, 0
local cabinetX, cabinetY, cabinetW, cabinetH = 0, 0, 0, 0
local spriteCabinet = nil
local spriteMarquee = nil
local textSprites = {}
local textShadows = {}
local textFirst = 0 -- The first line of the details on show, which walks by itself.
local textDown = true -- and which way it is walking.
local textClock = 0
local SHADOW = 2 -- How far the shadow sits down and across from the text.
local SCROLL_HOLD = 3.0 -- Seconds the details rest at each end before moving.
local SCROLL_STEP = 1.1 -- and seconds a line while they are moving.
local textHeight = 0
local textLimit = 0
-- A games.dat entry need not carry every field, and one that is missing must not take the menu
-- down; the document renderer is tolerant the same way, through menuEscape.
local function field(value)
return tostring(value or "")
end
-- Shrinks a sprite to fit inside a region, keeping its proportions. spriteDraw blits at the
-- sprite's own size and a games.dat's artwork is whatever size the person who drew it chose, so
-- without this a big cabinet picture is drawn straight over whatever the page put beside it. Only
-- ever smaller: blowing a small picture up to fill the space makes it soft for nothing.
local function fitSprite(handle, boxW, boxH)
local width = spriteGetWidth(handle)
local height = spriteGetHeight(handle)
if (width > 0) and (height > 0) then
local scale = math.min(boxW / width, boxH / height)
if scale < 1.0 then
spriteScale(handle, scale)
end
end
end
-- Splits text into the lines that fit the width given, measuring with the selected font. The only
-- way to measure a string is to render it, so this is slow enough to be worth doing once when
-- something changes rather than once a frame. WRAP_BREAK is a word the caller puts in to force a
-- line ending.
--
-- The space between words is kept as it was written rather than squeezed to one, because a tool
-- lines its columns up with runs of spaces and there is no other way here to say "a wide gap".
local function fitLines(text, maxWidth)
local lines = {}
local line = ""
local pending = ""
local trimBreak = utilTrim(WRAP_BREAK)
for word, space in (text .. WRAP_BREAK):gmatch("(%S+)(%s*)") do
if word == trimBreak then
lines[#lines + 1] = line
line = ""
pending = ""
else
local candidate = line .. pending .. word
local measured = fontToSprite(candidate)
local tooWide = spriteGetWidth(measured) > maxWidth
spriteUnload(measured)
if tooWide and line ~= "" then
lines[#lines + 1] = line
line = word
else
line = candidate
end
pending = space
end
end
if line ~= "" then
lines[#lines + 1] = line
end
return lines
end
-- The service tools, laid out. Each entry is one line of the page and each line is a list of
-- pieces: where the piece starts, what it says and which colour it wants. The colours are roles
-- rather than values because the audio delay tool inverts the whole page for a frame, and fontPrint
-- takes the colour at the moment it draws -- unlike fontToSprite, which bakes it in.
local toolLines = {}
local function toolLine(pieces)
toolLines[#toolLines + 1] = pieces
end
-- Text too long for the page wraps under itself rather than running off the edge.
local function toolWrapped(text, x, role)
if text == nil or text == "" then
-- Still a line: a tool that says nothing this frame should not have its page jump about.
toolLine({})
return
end
for _, line in ipairs(fitLines(text, overlayGetWidth() - MARGIN_X - x)) do
toolLine({ { x = x, text = line, role = role } })
end
end
local function toolsLayout()
local valueX = MARGIN_X + LABEL_W
local wasKeys = false
-- Only the rows that fit, with the chosen one kept in view; a catalogue is as long as the
-- service is big and would otherwise run off the bottom of the screen.
local rows, more, below = toolsVisible(TOOL_ROWS, toolsCapacity())
toolLines = {}
if more then
toolLine({ { x = MARGIN_X, text = " ...", role = "quiet" } })
end
local seenItem = false
for _, row in ipairs(rows) do
-- The "more below" marker belongs immediately under the last item, not at the very bottom
-- of the page under the key hints, which are not part of the list.
if below and seenItem and row.kind ~= "item" then
toolLine({ { x = MARGIN_X, text = " ...", role = "quiet" } })
below = false
end
if row.kind == "item" then
seenItem = true
end
-- The key hints stand away from whatever the tool put above them, as they do in the
-- document, but sit together when a tool needs more than one line of them.
if row.kind == "keys" and not wasKeys then
toolLine({})
end
wasKeys = (row.kind == "keys")
if row.kind == "blank" then
toolLine({})
elseif row.kind == "pair" then
-- The label and the first line of the value share a line; the rest of the value wraps
-- under itself, in its own column.
local lines = fitLines(row.value, overlayGetWidth() - MARGIN_X - valueX)
toolLine({ { x = MARGIN_X, text = row.label, role = "quiet" },
{ x = valueX, text = lines[1] or "", role = "ink" } })
for index = 2, #lines do
toolLine({ { x = valueX, text = lines[index], role = "ink" } })
end
elseif row.kind == "sub" then
toolWrapped(row.text, MARGIN_X + SUB_INDENT, "quiet")
elseif row.kind == "keys" then
toolWrapped(row.text, MARGIN_X, "quiet")
elseif row.kind == "item" then
toolLine({ { x = MARGIN_X, text = (row.selected and "> " or " ") .. row.text,
role = row.selected and "pick" or "ink" } })
else
toolWrapped(row.text, MARGIN_X, "ink")
end
end
-- A list that runs to the very end of the page has no row after it to hang the marker on.
if below then
toolLine({ { x = MARGIN_X, text = " ...", role = "quiet" } })
end
end
MENU_RENDER = {}
MENU_RENDER.begin = function()
font = fontLoad("Singe/FreeSansBold.ttf", 18)
hintFont = fontLoad("Singe/FreeSansBold.ttf", 13)
fontQuality(FONT_QUALITY_BLENDED)
fontSelect(font)
end
MENU_RENDER.finish = function()
if font then
fontUnload(font)
font = nil
end
if hintFont then
fontUnload(hintFont)
hintFont = nil
end
end
-- The backdrop is the disc: the engine intro, then the grid section played over and over. A
-- machine with no GPU device has nothing to draw a grid with, so this renderer plays the one in
-- Singe/menuBackground.mkv; Singe/MenuDocument.singe draws its own instead.
MENU_RENDER.backdropBegin = function(showIntro)
introDone = not showIntro
discPlay()
if not showIntro then
discSkipToFrame(DISC_GRID_START)
end
end
MENU_RENDER.backdrop = function()
-- Loop the grid. A machine too slow to show every disc frame can step over the last one, after
-- which the engine starts the disc again from the top; once the intro has played, that is a loop.
if discGetFrame() >= DISC_LAST_FRAME or (introDone and discGetFrame() < DISC_GRID_START) then
discSkipToFrame(DISC_GRID_START)
end
if discGetFrame() >= DISC_GRID_START then
introDone = true
end
-- Transparent, so the disc behind the overlay is what shows.
colorBackground(0, 0, 0, 0)
overlayClear()
end
MENU_RENDER.backdropReady = function()
-- The menu comes up where the drawn backdrop's logo leaves, which is before the picture starts
-- repeating: the frames between the two are the grid running on while the music fades out.
return discGetFrame() >= DISC_MENU_FRAME
end
-- The cabinet fills the left; the marquee, the attract video and the details stack down the right.
-- The page: the cabinet on the left, the marquee and the attract video down the right, the game's
-- details across the bottom and the key hints under them. The details used to have the video's
-- column and whatever height was left over, which at 720x480 was four lines -- the description and
-- about one field -- so most of what a games.dat says never reached the screen at all. They get
-- the full width now, and the fields are grouped the way the document renderer groups them, so the
-- whole entry fits. Everything is worked out from the overlay's size rather than written down,
-- because a game may set any size it likes.
MENU_RENDER.layout = function()
local width = overlayGetWidth()
local height = overlayGetHeight()
local sideW = math.floor(width * SIDE_FRAC)
local sideX = width - MARGIN - sideW
marqueeW = sideW
marqueeH = math.floor(sideW * MARQUEE_ASPECT_H / MARQUEE_ASPECT_W)
marqueeX = sideX
marqueeY = MARGIN
videoW = sideW
videoH = math.floor(sideW * VIDEO_ASPECT_H / VIDEO_ASPECT_W)
videoX = sideX
videoY = marqueeY + marqueeH + GAP
hintsX = MARGIN
hintsY = height - MARGIN - HINT_H
textX = MARGIN
textY = videoY + videoH + GAP
textW = width - MARGIN - textX
textH = hintsY - GAP - textY
cabinetX = MARGIN
cabinetY = MARGIN
cabinetW = sideX - GAP - cabinetX
cabinetH = textY - GAP - cabinetY
end
MENU_RENDER.buildList = function()
-- One game is on screen at a time; there is no list to build.
end
MENU_RENDER.showGame = function(game)
spriteCabinet = spriteLoad(menuArtPath(game.CABINET))
spriteMarquee = spriteLoad(menuArtPath(game.MARQUEE))
fitSprite(spriteCabinet, cabinetW, cabinetH)
fitSprite(spriteMarquee, marqueeW, marqueeH)
-- The details, grouped the way the document renderer groups them: the short fields share a
-- line, and the description comes last because it is the one that can run on. It used to come
-- first, which meant a long description pushed every fact about the game off the bottom.
local textBox = "Year: " .. field(game.YEAR) .. " Genre: " .. field(game.GENRE) ..
" Platform: " .. field(game.PLATFORM) .. WRAP_BREAK ..
"Developer: " .. field(game.DEVELOPER) .. WRAP_BREAK ..
"Publisher: " .. field(game.PUBLISHER) .. WRAP_BREAK ..
"Singe port: " .. field(game.CREATOR) .. " Source: " .. field(game.SOURCE) .. WRAP_BREAK ..
WRAP_BREAK .. field(game.DESCRIPTION)
-- fontToSprite bakes the colour in, so it is set before the sprites are made rather than
-- before they are drawn.
-- Twice over: black first, drawn a couple of pixels down and across behind the white, because
-- this page sits on a moving grid and white on magenta is hard to read. The document renderer
-- puts a translucent panel behind its text; nothing here can fill a rectangle, and a shadow
-- costs one more blit a line rather than one more surface a frame.
local lines = fitLines(textBox, textW)
colorForeground(0, 0, 0, 255)
for _, line in ipairs(lines) do
textShadows[#textShadows + 1] = (string.len(line) > 0) and fontToSprite(line) or -1
end
colorForeground(255, 255, 255, 255)
for _, line in ipairs(lines) do
textSprites[#textSprites + 1] = (string.len(line) > 0) and fontToSprite(line) or -1
end
-- How tall a line is, and how many of them fit under the title. Measured here rather than
-- part way through drawing, because the scroll has to know before the first line is drawn.
textHeight = 0
for _, handle in ipairs(textSprites) do
if (textHeight == 0) and (handle >= 0) then
textHeight = spriteGetHeight(handle) + 1
end
end
textLimit = (textHeight > 0) and math.max(math.floor((textH - LINE_HEIGHT) / textHeight), 1) or 1
textFirst = 0
textDown = true
textClock = 0
end
MENU_RENDER.hideGame = function()
if spriteCabinet then
spriteUnload(spriteCabinet)
spriteCabinet = nil
end
if spriteMarquee then
spriteUnload(spriteMarquee)
spriteMarquee = nil
end
for _, handle in ipairs(textSprites) do
if handle >= 0 then
spriteUnload(handle)
end
end
for _, handle in ipairs(textShadows) do
if handle >= 0 then
spriteUnload(handle)
end
end
textSprites = {}
textShadows = {}
end
-- Page up and page down, by as much as is on show less a line to read across. The walk resumes
-- from where this leaves it, in the direction it was sent, after its usual rest.
MENU_RENDER.scrollDetails = function(direction)
local room = #textSprites - textLimit
if room > 0 then
textFirst = math.max(math.min(textFirst + direction * math.max(textLimit - 1, 1), room), 0)
textDown = (direction > 0)
textClock = singeGetTicks()
end
end
MENU_RENDER.pageSize = function()
return PAGE_ROWS
end
MENU_RENDER.frame = function()
local x = 0
local y = 0
-- Cabinet image
x = cabinetX + (cabinetW - spriteGetWidth(spriteCabinet)) * 0.5
y = cabinetY + (cabinetH - spriteGetHeight(spriteCabinet)) * 0.5
spriteDraw(spriteCabinet, x, y)
-- Marquee image
x = marqueeX + (marqueeW - spriteGetWidth(spriteMarquee)) * 0.5
y = marqueeY + (marqueeH - spriteGetHeight(spriteMarquee)) * 0.5
spriteDraw(spriteMarquee, x, y)
-- Attract mode video
if VIDEO_ATTRACT then
videoDraw(VIDEO_ATTRACT, videoX, videoY, videoX + videoW, videoY + videoH)
end
-- Which game this is, since there is no list to look at. It heads the details rather than
-- sitting over the cabinet artwork, where it was unreadable against whatever the picture was.
local title = string.format("%s (%d of %d)", field(GAME_LIST[GAME_SELECTED].TITLE), GAME_SELECTED, GAME_COUNT)
colorForeground(0, 0, 0, 255)
fontPrint(textX + SHADOW, textY + SHADOW, title)
colorForeground(255, 211, 90, 255)
fontPrint(textX, textY, title)
-- What the game is. More of it than fits walks a line at a time, down to the end and back up
-- to the start, so everything an entry says is seen without a key to scroll it -- and without
-- the jump back to the top that reading the last line used to end in.
local room = #textSprites - textLimit
-- The clock starts when the details are first drawn, not when they were built: the menu is
-- behind the intro for the first seven seconds, and a page that had already scrolled itself by
-- the time anybody saw it would appear to start half way down.
if textClock == 0 then
textClock = singeGetTicks()
end
if room > 0 then
local resting = (textFirst <= 0) or (textFirst >= room)
if (singeGetTicks() - textClock) >= ((resting and SCROLL_HOLD or SCROLL_STEP) * 1000) then
if textDown and (textFirst >= room) then
textDown = false
elseif not textDown and (textFirst <= 0) then
textDown = true
end
textFirst = math.max(math.min(textFirst + (textDown and 1 or -1), room), 0)
textClock = singeGetTicks()
end
end
y = textY + LINE_HEIGHT
for index = textFirst + 1, math.min(textFirst + textLimit, #textSprites) do
if textSprites[index] >= 0 then
spriteDraw(textShadows[index], textX + SHADOW, y + SHADOW)
spriteDraw(textSprites[index], textX, y)
end
y = y + textHeight
end
-- Which key does what. The document renderer says the same thing along its footer; here
-- there was nothing at all, so the only way to learn that up and down move one game and left
-- and right move a page was to be told.
fontSelect(hintFont)
colorForeground(0, 0, 0, 255)
fontPrint(hintsX + SHADOW, hintsY + SHADOW, HINTS)
colorForeground(178, 196, 224, 255)
fontPrint(hintsX, hintsY, HINTS)
fontSelect(font)
end
MENU_RENDER.tools = function()
local y = MARGIN_Y
-- A tool that draws its own picture into the overlay has already done so, and the page has to
-- let it through; every other tool gets a page painted over everything.
if not TOOL_CLEAR then
if TOOL_FLASH then
colorBackground(255, 255, 255, 255)
else
colorBackground(0, 0, 0, 255)
end
overlayClear()
end
-- Measuring text means rendering it, so the page is laid out only when a tool says it changed.
if TOOL_DIRTY then
TOOL_DIRTY = false
toolsLayout()
end
local ink = TOOL_FLASH and 0 or 255
local quiet = TOOL_FLASH and 90 or 160
-- A page that lets the overlay through gathers its text along the bottom, out of the way of
-- whatever the tool drew: the light gun's corner targets in particular.
if TOOL_CLEAR then
y = overlayGetHeight() - MARGIN_Y - (#toolLines + 1) * LINE_HEIGHT
end
colorForeground(ink, ink, ink, 255)
fontPrint(MARGIN_X, y, TOOL_TITLE)
y = y + LINE_HEIGHT
if not TOOL_CLEAR then
y = y + LINE_HEIGHT
end
for _, line in ipairs(toolLines) do
for _, piece in ipairs(line) do
if piece.role == "quiet" then
colorForeground(quiet, quiet, quiet, 255)
elseif piece.role == "pick" then
colorForeground(255, 211, 90, 255)
else
colorForeground(ink, ink, ink, 255)
end
-- fontPrint refuses an empty string, and a tool legitimately produces one: a status
-- line with nothing to say yet is still a line, and it still takes up its height.
if piece.text ~= "" then
fontPrint(piece.x, y, piece.text)
end
end
y = y + LINE_HEIGHT
end
end

View file

@ -1,467 +0,0 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program 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; either version 3
* of the License, or (at your option) any later version.
*
* This program 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. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- Talking to the master service without stopping the game.
--
-- Lua in Singe is not threaded, and ssl.https.request blocks until it finishes: a JSON call would
-- stutter the frame and a several-hundred-megabyte download would look like a hang. So every
-- request here is a state machine driven a slice at a time from netPump(), which the menu calls
-- once a frame. A caller hands in a function and gets called back when the answer arrives.
--
-- On trust. LuaSec's own https.lua ships with verify = "none", which encrypts the connection and
-- authenticates nobody -- anything on the path could read the password going up, the token coming
-- back, and alter the game being downloaded. Nothing here uses that module. A connection is
-- verified against a pinned public key: Singe ships to five platforms and LuaSec can read neither
-- the Windows certificate store nor the macOS keychain, and this client only ever talks to one
-- service, so pinning is both simpler and stronger than carrying a list of every public CA.
--
-- The pin is of the *public key*, not the certificate, so an ordinary renewal that keeps the key
-- does not lock out every cabinet; two pins are carried so a key can actually be rotated.
--
-- Without a pin the chain is verified against a CA list. Linux has one; Windows, macOS and the
-- handhelds have nothing LuaSec can read, so the engine extracts Mozilla's list as
-- Singe/cacerts.pem beside the other support files and that is used wherever the platform's own
-- is missing.
local socket = require("socket")
local ssl = require("ssl")
NET_TIMEOUT = 30000 -- ms with no progress at all before a request is abandoned
NET_CHUNK = 65536 -- bytes read per pump slice
NET_REDIRECT_MAX = 5
-- master.singeengine.com. Two pins: the one in use and its successor, so a key rotation is a
-- release that knows both, then a server that starts using the second. An empty list means the
-- host's own trust store is used instead, which is what a self-hosted server without a pin gets.
NET_PINS = {}
NET_CA_SYSTEM = "/etc/ssl/certs/ca-certificates.crt" -- Linux's list, when it is there,
NET_CA_SHIPPED = "Singe/cacerts.pem" -- and the one Singe carries otherwise.
local active = {}
local nextId = 1
-- The SHA-256 of a peer's public key, in the form the pins are written in.
local function keyPin(connection)
local cert = connection:getpeercertificate()
if not cert then
return nil
end
local pem = cert:pubkey()
if not pem then
return nil
end
return utilSha256(pem)
end
local function finish(request, ok, result)
active[request.id] = nil
if request.file then
request.file:close()
request.file = nil
end
if request.socket then
pcall(function() request.socket:close() end)
request.socket = nil
end
if request.onDone then
request.onDone(ok, result)
end
end
local function fail(request, message)
finish(request, false, { error = message, url = request.url })
end
-- scheme, host, port, path
local function parseUrl(url)
local scheme, rest = url:match("^(%a[%w+.-]*)://(.*)$")
if not scheme then
return nil
end
local hostPort, path = rest:match("^([^/]+)(.*)$")
if not hostPort then
return nil
end
local host, port = hostPort:match("^(.-):(%d+)$")
host = host or hostPort
port = tonumber(port) or ((scheme == "https") and 443 or 80)
return scheme, host, port, (path ~= "" and path or "/")
end
local function beginConnect(request)
local sock, err = socket.tcp()
if not sock then
return fail(request, "no socket: " .. tostring(err))
end
sock:settimeout(0)
request.socket = sock
-- A non-blocking connect answers "timeout" immediately and finishes in the background; the
-- socket becoming writable is what says it arrived.
local ok, connectErr = sock:connect(request.host, request.port)
if not ok and connectErr ~= "timeout" and connectErr ~= "already connected" then
return fail(request, "cannot reach " .. request.host .. ": " .. tostring(connectErr))
end
request.state = (request.scheme == "https") and "connecting" or "sending"
request.sent = 0
end
local function buildRequest(request)
local lines = {
string.format("%s %s HTTP/1.1", request.method, request.path),
"Host: " .. request.host,
"Connection: close",
"Accept-Encoding: identity",
"User-Agent: Singe/" .. SINGE_VERSION_STRING,
}
for name, value in pairs(request.headers or {}) do
lines[#lines + 1] = name .. ": " .. value
end
if request.body then
lines[#lines + 1] = "Content-Length: " .. #request.body
end
return table.concat(lines, "\r\n") .. "\r\n\r\n" .. (request.body or "")
end
-- The CA list a connection without a pin is verified against.
local function caFile()
local lfs = require("lfs")
if lfs.attributes(NET_CA_SYSTEM, "mode") == "file" then
return NET_CA_SYSTEM
end
return NET_CA_SHIPPED
end
local function startTls(request)
local params = {
mode = "client",
protocol = "any",
options = { "all", "no_sslv2", "no_sslv3", "no_tlsv1", "no_tlsv1_1" },
-- With a pin, the chain is checked against the key rather than a CA list, and the pin is
-- what decides. Without one, a CA list is asked -- and if it has nothing to say, the
-- request fails rather than proceeding unverified.
verify = (#NET_PINS > 0) and "none" or "peer",
cafile = caFile(),
}
local wrapped, err = ssl.wrap(request.socket, params)
if not wrapped then
return fail(request, "TLS setup failed: " .. tostring(err))
end
wrapped:settimeout(0)
if wrapped.sni then
wrapped:sni(request.host)
end
request.socket = wrapped
request.state = "handshake"
end
local function pumpHandshake(request)
local ok, err = request.socket:dohandshake()
if ok then
if #NET_PINS > 0 then
local seen = keyPin(request.socket)
local good = false
for _, pin in ipairs(NET_PINS) do
if seen == pin then
good = true
end
end
if not good then
-- The one failure that must never be shrugged off: a wrong key is either a
-- mis-deployed server or somebody in the middle, and both want a person.
return fail(request, "the server's key is not one this build trusts")
end
end
request.state = "sending"
return
end
if err ~= "wantread" and err ~= "wantwrite" and err ~= "timeout" then
fail(request, "TLS handshake failed: " .. tostring(err))
end
end
local function pumpSending(request)
if not request.outgoing then
request.outgoing = buildRequest(request)
end
local sent, err, partial = request.socket:send(request.outgoing, request.sent + 1)
if sent then
request.sent = sent
elseif err == "timeout" then
request.sent = partial or request.sent
else
return fail(request, "send failed: " .. tostring(err))
end
if request.sent >= #request.outgoing then
request.state = "reading"
end
end
-- Split the headers off once the blank line arrives, so the body can start going to disk rather
-- than accumulating in memory: a game is far too big to hold twice.
local function splitHead(request)
local at = request.buffer:find("\r\n\r\n", 1, true)
if not at then
return false
end
local head = request.buffer:sub(1, at - 1)
request.buffer = request.buffer:sub(at + 4)
request.status = tonumber(head:match("^HTTP/%d%.%d (%d%d%d)")) or 0
request.responseHeaders = {}
for name, value in head:gmatch("\r\n([^:\r\n]+):%s*([^\r\n]*)") do
request.responseHeaders[name:lower()] = value
end
request.length = tonumber(request.responseHeaders["content-length"])
request.got = 0
-- HTTP/1.1 may send the body in sized pieces instead of announcing a length; those are
-- unwrapped as they arrive and the last, empty, piece is the end of the body.
request.chunked = (request.responseHeaders["transfer-encoding"] or ""):lower():find("chunked", 1, true) ~= nil
request.chunkBuffer = ""
request.chunkLeft = nil
request.chunkDone = false
if request.toFile then
local file, err = io.open(request.toFile, "wb")
if not file then
fail(request, "cannot write " .. request.toFile .. ": " .. tostring(err))
return false
end
request.file = file
end
return true
end
local function consume(request, data)
request.got = request.got + #data
if request.file then
request.file:write(data)
else
request.parts[#request.parts + 1] = data
end
if request.onProgress then
request.onProgress(request.got, request.length)
end
end
-- Body bytes arriving under chunked transfer encoding: a hexadecimal size on a line, that many
-- bytes, a blank line, again until a size of zero. Whatever is left over waits for the next read.
local function consumeChunked(request, data)
request.chunkBuffer = request.chunkBuffer .. data
while not request.chunkDone do
if request.chunkLeft == nil then
local at = request.chunkBuffer:find("\r\n", 1, true)
if not at then
return
end
local size = tonumber(request.chunkBuffer:sub(1, at - 1):match("^%x+"), 16)
request.chunkBuffer = request.chunkBuffer:sub(at + 2)
if size == nil then
return fail(request, "the server sent a chunk with no size")
end
if size == 0 then
request.chunkDone = true
return
end
request.chunkLeft = size
elseif request.chunkLeft > 0 then
local take = request.chunkBuffer:sub(1, request.chunkLeft)
if #take == 0 then
return
end
request.chunkBuffer = request.chunkBuffer:sub(#take + 1)
request.chunkLeft = request.chunkLeft - #take
consume(request, take)
else
-- The blank line after a chunk's bytes.
if #request.chunkBuffer < 2 then
return
end
request.chunkBuffer = request.chunkBuffer:sub(3)
request.chunkLeft = nil
end
end
end
local function complete(request)
local body = request.file and "" or table.concat(request.parts)
-- A redirect is followed only for a plain GET, and only within the same scheme and host: the
-- point of a pin is lost if a 302 can walk the client somewhere else.
local location = request.responseHeaders["location"]
if location and request.status >= 300 and request.status < 400 and request.redirects < NET_REDIRECT_MAX then
local scheme, host = parseUrl(location)
if scheme == request.scheme and host == request.host then
request.redirects = request.redirects + 1
request.url = location
local _, _, port, path = parseUrl(location)
request.port, request.path = port, path
request.buffer, request.parts, request.outgoing, request.sent = "", {}, nil, 0
request.chunked, request.chunkBuffer, request.chunkLeft, request.chunkDone = false, "", nil, false
if request.socket then pcall(function() request.socket:close() end) end
request.socket = nil
return beginConnect(request)
end
end
finish(request, true, { status = request.status, body = body, headers = request.responseHeaders,
bytes = request.got, file = request.toFile })
end
local function pumpReading(request)
local data, err, partial = request.socket:receive(NET_CHUNK)
local got = data or partial
if got and #got > 0 then
request.lastProgress = singeGetTicks()
if not request.responseHeaders then
request.buffer = request.buffer .. got
if splitHead(request) and #request.buffer > 0 then
local rest = request.buffer
request.buffer = ""
if request.chunked then
consumeChunked(request, rest)
else
consume(request, rest)
end
end
elseif request.chunked then
consumeChunked(request, got)
else
consume(request, got)
end
end
if not active[request.id] then
return -- A bad chunk failed the request from inside consumeChunked.
end
if request.responseHeaders and ((request.length and request.got >= request.length) or request.chunkDone) then
return complete(request)
end
if err == "closed" then
if request.responseHeaders then
return complete(request)
end
return fail(request, "the server closed before answering")
end
if err and err ~= "timeout" and err ~= "wantread" then
fail(request, "read failed: " .. tostring(err))
end
end
-- One slice of work for every request in flight. Cheap when nothing is happening.
--
-- The list is snapshotted first because a request that finishes calls back into the caller, and the
-- caller very reasonably starts another one from there -- a download that reports itself installed,
-- say. Adding to a table while pairs() walks it is not allowed, and the failure ("invalid key to
-- 'next'") names neither the table nor the callback that did it.
function netPump()
local running = {}
for id in pairs(active) do
running[#running + 1] = id
end
for _, id in ipairs(running) do
local request = active[id]
if request then
if singeGetTicks() - request.lastProgress > NET_TIMEOUT then
fail(request, "the server stopped responding")
elseif request.state == "connecting" then
-- Writable means the connect finished; LuaSec is wrapped around it only then.
local _, writable = socket.select(nil, { request.socket }, 0)
if writable and #writable > 0 then
request.lastProgress = singeGetTicks()
startTls(request)
end
elseif request.state == "handshake" then
pumpHandshake(request)
elseif request.state == "sending" then
pumpSending(request)
elseif request.state == "reading" then
pumpReading(request)
end
end
end
end
-- Start a request. opts: url, method, headers, body, toFile, onProgress. onDone(ok, result) is
-- called once, with result.status/body/headers on success or result.error on failure.
function netRequest(opts, onDone)
local scheme, host, port, path = parseUrl(opts.url)
if not scheme or (scheme ~= "http" and scheme ~= "https") then
onDone(false, { error = "not a URL this understands: " .. tostring(opts.url) })
return nil
end
local request = {
id = nextId, url = opts.url, scheme = scheme, host = host, port = port, path = path,
method = opts.method or "GET", headers = opts.headers, body = opts.body,
toFile = opts.toFile, onProgress = opts.onProgress, onDone = onDone,
buffer = "", parts = {}, redirects = 0, lastProgress = singeGetTicks(),
}
nextId = nextId + 1
active[request.id] = request
beginConnect(request)
return request.id
end
function netCancel(id)
local request = active[id]
if request then
request.onDone = nil
finish(request, false, { error = "cancelled" })
end
end
function netBusy()
return next(active) ~= nil
end

BIN
assets/Plaque-champagne.stl (Stored with Git LFS)

Binary file not shown.

BIN
assets/Plaque-dark.stl (Stored with Git LFS)

Binary file not shown.

BIN
assets/Plaque-frame.stl (Stored with Git LFS)

Binary file not shown.

BIN
assets/Plaque-glass.stl (Stored with Git LFS)

Binary file not shown.

BIN
assets/Plaque-plate.stl (Stored with Git LFS)

Binary file not shown.

View file

@ -1,56 +0,0 @@
# Materials for Plaque.obj
newmtl dark
Ka 0.060 0.062 0.066
Kd 0.300 0.310 0.330
Ks 0.700 0.700 0.700
Ns 180.0
d 1.00
Ni 1.00
illum 2
Pm 0.80
Pr 0.35
newmtl champagne
Ka 0.144 0.128 0.108
Kd 0.720 0.640 0.540
Ks 0.850 0.800 0.700
Ns 120.0
d 1.00
Ni 1.00
illum 2
Pm 0.80
Pr 0.40
newmtl glass
Ka 0.176 0.184 0.180
Kd 0.880 0.920 0.900
Ks 0.950 0.950 0.950
Ns 300.0
d 0.55
Ni 1.50
illum 4
Pm 0.00
Pr 0.15
newmtl frame
Ka 0.024 0.022 0.020
Kd 0.120 0.110 0.100
Ks 0.300 0.300 0.300
Ns 40.0
d 1.00
Ni 1.00
illum 2
Pm 0.00
Pr 0.70
newmtl plate
Ka 0.110 0.106 0.100
Kd 0.550 0.530 0.500
Ks 0.200 0.200 0.200
Ns 30.0
d 1.00
Ni 1.00
illum 2
Pm 0.00
Pr 0.80

File diff suppressed because it is too large Load diff

View file

@ -1,117 +0,0 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program 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; either version 3
* of the License, or (at your option) any later version.
*
* This program 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. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
local socket = require("socket")
local copas = require("copas")
local https = require("copas.http").request
dofile("Singe/Framework.singe")
function get(url)
local download = {}
download.url = url
download.finished = false
copas.addthread(function(d)
d.result, d.error = https(d.url)
d.finished = true
end,
download)
return download
end
function onInputPressed(what)
if what == SWITCH_LEFT then
discStepBackward()
end
if what == SWITCH_RIGHT then
discStepForward()
end
end
function onOverlayUpdate()
copas.step()
colorBackground(0, 0, 0, 127) -- this dims the background 50%
overlayClear()
overlayPrint(1, 1, dldGameList.url.." "..(dldGameList.finished and 'true' or 'false'))
if dldGameList.finished then
overlayPrint(1, 2, dldGameList.error)
overlayPrint(1, 3, dldGameList.result)
end
colorForeground(255, 0, 0, 255)
overlayBox(0, 0, overlayGetWidth() - 1, overlayGetHeight() - 1)
spriteDraw(sprServiceMenu, (overlayGetWidth() - spriteGetWidth(sprServiceMenu)) / 2, 25)
return(OVERLAY_UPDATED)
end
function onShutdown()
spriteUnload(sprServiceMenu)
fontUnload(fntSans18)
fontUnload(fntSans32)
end
copas.running = true
dldGameList = get("https://kangaroopunch.com/api/singeSoftware")
overlaySetResolution(discGetWidth(), discGetHeight())
DISC_SINGE_LOGO = 140
discSkipToFrame(DISC_SINGE_LOGO)
discPause()
fontQuality(FONT_QUALITY_BLENDED)
fntSans18 = fontLoad("Singe/FreeSansBold.ttf", 18)
fntSans32 = fontLoad("Singe/FreeSansBold.ttf", 32)
fontSelect(fntSans32)
sprServiceMenu = fontToSprite("Singe Service Menu")
--[[
Game Management
--]]

BIN
assets/SingeText.glb (Stored with Git LFS)

Binary file not shown.

BIN
assets/SingeText.jpeg (Stored with Git LFS)

Binary file not shown.

View file

@ -1,12 +0,0 @@
# Materials for SingeText.obj
newmtl logo
Ka 0.060 0.062 0.066
Kd 0.300 0.310 0.330
Ks 0.700 0.700 0.700
Ns 180.0
d 1.00
Ni 1.00
illum 2
Pm 0.80
Pr 0.35

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

BIN
assets/SingeTextPrint-face.stl (Stored with Git LFS)

Binary file not shown.

BIN
assets/SingeTextPrint-plate.stl (Stored with Git LFS)

Binary file not shown.

BIN
assets/SingeTextPrint-walls.stl (Stored with Git LFS)

Binary file not shown.

View file

@ -1,34 +0,0 @@
# Materials for SingeTextPrint.obj
newmtl dark
Ka 0.060 0.062 0.066
Kd 0.300 0.310 0.330
Ks 0.700 0.700 0.700
Ns 180.0
d 1.00
Ni 1.00
illum 2
Pm 0.80
Pr 0.35
newmtl frame
Ka 0.024 0.022 0.020
Kd 0.120 0.110 0.100
Ks 0.300 0.300 0.300
Ns 40.0
d 1.00
Ni 1.00
illum 2
Pm 0.00
Pr 0.70
newmtl champagne
Ka 0.144 0.128 0.108
Kd 0.720 0.640 0.540
Ks 0.850 0.800 0.700
Ns 120.0
d 1.00
Ni 1.00
illum 2
Pm 0.80
Pr 0.40

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,74 +0,0 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program 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; either version 3
* of the License, or (at your option) any later version.
*
* This program 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. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- Default Mappings
-- How far an axis must move before it counts as pressed, in raw axis units: SDL reports 32767 at
-- full deflection, so 15000 is a little under half way. It covers every axis of every gamepad,
-- the two analogue triggers included.
DEAD_ZONE = 15000
-- One axis at a time, for a stiff stick or a worn trigger, in the same raw units. Each entry is
-- { axis, dead zone }, where the axis is any GAMEPAD_N axis entry -- the axis itself or either of
-- its two directions, which all name the same axis. Anything not listed keeps DEAD_ZONE.
DEAD_ZONES = {
-- { GAMEPAD_0.AXIS_LEFT_X, 8000 },
-- { GAMEPAD_0.AXIS_LEFT_Y, 8000 },
}
-- How far an analogue trigger must travel before it counts as a button, as a per cent of full
-- travel: Hypseus's unit, where its own default is 99.5. Zero leaves the triggers on DEAD_ZONE,
-- which is what Singe has always given them, and --trigger_threshold on the command line wins over
-- whatever is set here. 15000 raw units is 45.8 per cent, for comparison.
TRIGGER_THRESHOLD = 0
-- One table per switch, in the order the engine numbers them (SWITCH_UP = 0 ...).
-- Each entry is a { name, value } pair from the SCANCODE, GAMEPAD_N, or MOUSE_N tables.
INPUT_UP = { SCANCODE.UP, SCANCODE.KP_8, GAMEPAD_0.AXIS_LEFT_Y_U, GAMEPAD_0.AXIS_RIGHT_Y_U, GAMEPAD_0.DPAD_UP }
INPUT_LEFT = { SCANCODE.LEFT, SCANCODE.KP_4, GAMEPAD_0.AXIS_LEFT_X_L, GAMEPAD_0.AXIS_RIGHT_X_L, GAMEPAD_0.DPAD_LEFT }
INPUT_DOWN = { SCANCODE.DOWN, SCANCODE.KP_2, GAMEPAD_0.AXIS_LEFT_Y_D, GAMEPAD_0.AXIS_RIGHT_Y_D, GAMEPAD_0.DPAD_DOWN }
INPUT_RIGHT = { SCANCODE.RIGHT, SCANCODE.KP_6, GAMEPAD_0.AXIS_LEFT_X_R, GAMEPAD_0.AXIS_RIGHT_X_R, GAMEPAD_0.DPAD_RIGHT }
INPUT_1P_START = { SCANCODE.MAIN_1, GAMEPAD_0.BUTTON_RIGHT_BUMPER }
INPUT_2P_START = { SCANCODE.MAIN_2 }
INPUT_ACTION_1 = { SCANCODE.SPACE, SCANCODE.LCTRL, GAMEPAD_0.BUTTON_A, MOUSE_0.BUTTON_RIGHT }
INPUT_ACTION_2 = { SCANCODE.LALT, GAMEPAD_0.BUTTON_B, MOUSE_0.BUTTON_MIDDLE }
INPUT_ACTION_3 = { SCANCODE.LSHIFT, GAMEPAD_0.BUTTON_X, MOUSE_0.BUTTON_LEFT }
INPUT_1P_COIN = { SCANCODE.MAIN_5, SCANCODE.C, GAMEPAD_0.BUTTON_LEFT_BUMPER }
INPUT_2P_COIN = { SCANCODE.MAIN_6 }
INPUT_SKILL_EASY = { SCANCODE.KP_DIVIDE }
INPUT_SKILL_MEDIUM = { SCANCODE.KP_MULTIPLY }
INPUT_SKILL_HARD = { SCANCODE.KP_MINUS }
INPUT_SERVICE = { SCANCODE.MAIN_9 }
INPUT_TEST_MODE = { SCANCODE.F2 }
INPUT_RESET_CPU = { SCANCODE.F3 }
INPUT_SCREENSHOT = { SCANCODE.F12, SCANCODE.F11, GAMEPAD_0.BUTTON_BACK } -- F12 is taken by some desktops and debuggers, so F11 works too.
INPUT_QUIT = { SCANCODE.ESCAPE, SCANCODE.Q }
INPUT_PAUSE = { SCANCODE.P, GAMEPAD_0.BUTTON_START }
INPUT_CONSOLE = { SCANCODE.GRAVE }
INPUT_ACTION_4 = { SCANCODE.RSHIFT, GAMEPAD_0.BUTTON_Y, MOUSE_0.BUTTON_X1 }
INPUT_TILT = { SCANCODE.T }
INPUT_GRAB = { SCANCODE.G }
-- Not a control: the engine raises this one itself when a mouse or a light gun is unplugged, so a
-- gun game can say so on screen. Bind a key to it as well if you want to test what the game does.
INPUT_MOUSE_DISCONNECT = { }

BIN
assets/font.xcf (Stored with Git LFS)

Binary file not shown.

View file

@ -1,243 +0,0 @@
/*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* The shipped GUI theme: what a document looks like before a game styles it. Link it from a
* document with <link type="text/rcss" href="Singe/gui.rcss"/> and override what you like.
* Licensed under the GNU General Public License, version 3 or later.
*/
body {
font-family: FreeSans;
font-weight: bold;
font-size: 22dp;
color: #f0f0f0;
}
h1 {
display: block;
font-size: 40dp;
margin: 0 0 12dp 0;
color: #ffd35a;
}
h2 {
display: block;
font-size: 28dp;
margin: 0 0 8dp 0;
color: #ffd35a;
}
p {
display: block;
margin: 0 0 8dp 0;
}
div {
display: block;
}
.panel {
display: block;
background-color: #101828e0;
decorator: linear-gradient(180deg, #182640e0, #0c1424e0);
box-shadow: 0 6dp 14dp #00000090;
border: 2dp #ffd35a;
border-radius: 8dp;
padding: 16dp;
}
button,
input.submit {
display: inline-block;
padding: 8dp 20dp;
margin: 4dp;
background-color: #24344e;
decorator: linear-gradient(180deg, #2e4260, #1c2a42);
border: 2dp #6a86b8;
border-radius: 6dp;
color: #f0f0f0;
text-align: center;
cursor: pointer;
tab-index: auto;
transition: background-color border-color 0.12s;
}
button:hover,
input.submit:hover {
background-color: #33507a;
decorator: linear-gradient(180deg, #3f5f8e, #2a4470);
border-color: #ffd35a;
}
button:focus,
input.submit:focus {
border-color: #ffd35a;
}
button:active,
input.submit:active {
background-color: #ffd35a;
decorator: linear-gradient(180deg, #ffe08a, #f0c040);
color: #101828;
}
input.text,
input.password,
textarea {
display: inline-block;
padding: 6dp 10dp;
margin: 4dp;
background-color: #0a1220;
border: 2dp #6a86b8;
border-radius: 4dp;
color: #f0f0f0;
cursor: text;
tab-index: auto;
}
input.text:focus,
input.password:focus,
textarea:focus {
border-color: #ffd35a;
}
input.checkbox,
input.radio {
display: inline-block;
width: 22dp;
height: 22dp;
margin: 0 8dp 0 0;
background-color: #0a1220;
border: 2dp #6a86b8;
vertical-align: middle;
tab-index: auto;
}
input.radio {
border-radius: 11dp;
}
input.checkbox:checked,
input.radio:checked {
background-color: #ffd35a;
}
input.range {
display: inline-block;
height: 24dp;
width: 240dp;
margin: 4dp;
tab-index: auto;
}
input.range slidertrack {
height: 6dp;
margin: 9dp 0;
background-color: #24344e;
border-radius: 3dp;
}
input.range sliderbar {
width: 22dp;
height: 22dp;
margin: -8dp 0;
background-color: #ffd35a;
border-radius: 11dp;
}
select {
display: inline-block;
width: 240dp;
padding: 6dp 10dp;
margin: 4dp;
background-color: #0a1220;
border: 2dp #6a86b8;
border-radius: 4dp;
tab-index: auto;
}
select selectvalue {
width: auto;
margin-right: 30dp;
}
select selectarrow {
width: 24dp;
height: 24dp;
background-color: #6a86b8;
}
select selectbox {
background-color: #101828;
border: 2dp #6a86b8;
}
select selectbox option {
padding: 4dp 10dp;
}
select selectbox option:hover,
select selectbox option:checked {
background-color: #33507a;
}
progress {
display: block;
height: 18dp;
margin: 4dp 0;
background-color: #0a1220;
border: 2dp #6a86b8;
border-radius: 9dp;
}
progress fill {
background-color: #ffd35a;
decorator: linear-gradient(180deg, #ffe08a, #e6b830);
border-radius: 7dp;
}
scrollbarvertical {
width: 14dp;
background-color: #0a1220;
}
scrollbarvertical sliderbar {
background-color: #6a86b8;
border-radius: 7dp;
min-height: 24dp;
}
scrollbarvertical sliderbar:hover,
scrollbarvertical sliderbar:active {
background-color: #ffd35a;
}
scrollbarvertical sliderarrowdec,
scrollbarvertical sliderarrowinc {
height: 0;
}
.list {
display: block;
overflow-y: auto;
background-color: #0a1220;
border: 2dp #6a86b8;
border-radius: 4dp;
}
.list div {
padding: 6dp 10dp;
tab-index: auto;
}
.list div:hover,
.list div:focus,
.list div.selected {
background-color: #33507a;
color: #ffd35a;
}
.muted {
color: #8fa2c4;
}

BIN
assets/icon.xcf (Stored with Git LFS)

Binary file not shown.

BIN
assets/kangarooPunchLogo.xcf (Stored with Git LFS)

Binary file not shown.

View file

@ -1,256 +0,0 @@
/*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* The bundled menu's look on top of the shipped theme. Nothing here has a position or a size:
* Singe/Menu.singe places every region from the overlay size, so the attract video it draws
* underneath fills the #video hole exactly.
* Licensed under the GNU General Public License, version 3 or later.
*/
body {
width: 100%;
height: 100%;
font-size: 14dp;
}
/* Every region the script places; the border sits inside the rectangle it is given. */
.placed {
position: absolute;
box-sizing: border-box;
}
.hidden {
display: none;
}
/* The game list: the panel look (the theme's gradient and shadow), one row per game, the
selected one gold on a gradient. */
#list {
background-color: #0a1220d0;
decorator: linear-gradient(180deg, #182640d0, #0c1424d0);
box-shadow: 0 6dp 14dp #00000090;
padding: 4dp 0;
font-size: 13dp;
}
#list div {
padding: 3dp 8dp;
white-space: nowrap;
overflow: hidden;
cursor: pointer;
}
#list div:hover {
background-color: #24344e;
color: #f0f0f0;
}
#list div:focus {
background-color: transparent;
color: #f0f0f0;
}
#list div.selected,
#list div.selected:hover,
#list div.selected:focus {
background-color: #33507a;
decorator: linear-gradient(90deg, #4a6ea8, #33507a);
color: #ffd35a;
}
/* Poster and marquee: the script sets an image decorator that fits the picture inside. No border:
cabinet art and a marquee are pictures in their own right and a frame round them fights with
whatever frame the artwork already has. */
.art {
background-color: #0a122080;
}
/* The attract video shows through here; nothing is drawn over it. */
#video {
}
#info {
display: flex;
flex-direction: column;
padding: 6dp 10dp;
background-color: #101828d0;
border-color: #6a86b8;
}
#title {
font-size: 18dp;
color: #ffd35a;
margin-bottom: 2dp;
}
.line {
font-size: 11dp;
color: #c8d2e6;
}
.line .label {
color: #8fa2c4;
}
#description {
flex: 1;
margin-top: 4dp;
font-size: 13dp;
overflow-y: auto;
}
#footer {
display: flex;
flex-direction: row;
align-items: center;
background-color: #101828d0;
border: 2dp #6a86b8;
border-radius: 4dp;
padding: 0 8dp;
}
#hints {
font-size: 12dp;
color: #8fa2c4;
}
/* .hidden and .tool are one class each, and the pages that let the overlay through wear two, so a
page taken out of view has to be said again here or its display wins. */
.tool.hidden,
.tool.clear.hidden {
display: none;
}
/* The service tools: one opaque page over everything, the same for all of them. The audio delay
tool alone turns the page white for the frame of its flash, so the colours are named twice. */
.tool {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 1;
padding: 25dp;
box-sizing: border-box;
background-color: #000000;
color: #ffffff;
overflow-y: auto;
}
.tool.flash {
background-color: #ffffff;
color: #000000;
}
.tool h1 {
font-size: 24dp;
color: #ffffff;
margin-bottom: 20dp;
}
.tool.flash h1 {
color: #000000;
}
.tool p {
margin-bottom: 20dp;
}
.tool p.value {
margin-bottom: 4dp;
}
/* The key hints stand away from whatever the tool put above them, and close together among
themselves when a tool needs more than one line of them. */
.tool p.keys {
margin-top: 20dp;
margin-bottom: 4dp;
color: #8fa2c4;
}
.tool p.keys + p.keys {
margin-top: 0;
}
/* The rows a tool fills in for itself: a heading row and an indented detail row under it. Text
with nowhere to break -- a long path -- wraps mid-word rather than running off the edge, but an
ordinary word is left whole. */
.tool .row {
margin-bottom: 2dp;
word-break: break-word;
}
.tool .sub {
margin-left: 20dp;
margin-bottom: 2dp;
font-size: 12dp;
color: #c8d2e6;
word-break: break-word;
}
/* A label beside its value. The two are columns rather than one run of text, so a value too long
for the line wraps under itself instead of under the label. */
.tool .pair {
display: flex;
margin-bottom: 2dp;
}
.tool .pair .label {
flex: 0 0 110dp;
color: #8fa2c4;
}
.tool .pair .detail {
flex: 1 1 0;
word-break: break-word;
}
/* A line a tool offers to choose between, and the chosen one in the game list's gold -- the same
way the game list itself shows a selection, since RCSS has no generated content to mark it with. */
.tool .item {
margin-bottom: 2dp;
}
.tool .item.selected {
color: #ffd35a;
}
/* A gap a tool asked for. */
.tool .blank {
height: 12dp;
}
/* Two tools draw their own picture into the overlay, which is underneath the document: the light
gun's targets and the display's test pattern. Those pages let it through and gather their text
into a strip along the bottom, between the two lower targets, so nothing worth aiming at is
covered. */
.tool.clear {
background-color: transparent;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
}
.tool.clear h1 {
font-size: 16dp;
margin-bottom: 4dp;
}
.tool.clear h1,
.tool.clear .row,
.tool.clear .pair,
.tool.clear .sub,
.tool.clear .item,
.tool.clear p {
background-color: #000000c0;
padding: 1dp 8dp;
margin-bottom: 4dp;
}
/* The help under the list of tools, away from the list itself. */
#toolHelp {
margin-top: 12dp;
}

BIN
assets/menuBackground.mkv (Stored with Git LFS)

Binary file not shown.

BIN
assets/menuIntro.flac (Stored with Git LFS)

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show more