diff --git a/CHANGELOG b/CHANGELOG index 2390c5d27..7d56b4c9e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -71,6 +71,33 @@ API Changes [, argument]) starts another executable when this one ends. Neither the question nor the download needs an account. +- A game can say what it needs of a machine. REQUIRES in its games.dat lists + the capabilities it uses -- SCENE, GUI, SHADOWS, POST, SKIN, MORPH -- and no + REQUIRES means 2D; TIER (0 to 3) says how much machine it wants. You rarely + write either by hand: the engine notices what a game uses as it runs, says in + the trace when the game uses something its REQUIRES leaves out, and --pack + writes REQUIRES into an entry that has none. The menu measures this + machine's tier from its own backdrop the first time it runs, keeps it in + machine.cfg, and marks a game this machine cannot run (and says why) or may + run slowly. Nothing is hidden. Behind it: singeCapabilities() reports what + the machine can do, singeSaveTier(tier) keeps the tier, and sceneMeasure(on) + times the 3D scene's real work rather than the display's pacing. + +- Connections to the master service trust two lists of certificate + authorities at once: the one Singe ships and the one the machine keeps + (the distribution's file on Linux, the root store on Windows, the system + anchors on macOS). Either one vouching for the server is enough, so a + cabinet whose copy of Singe has aged can still fetch the update that + replaces it. A self-hosted service with its own authority puts that + certificate in cacerts.local.pem in the data folder, which Singe never + writes. A refused connection says which lists it checked and what the + machine's clock reads when a certificate is not valid yet, and says the + clock is wrong outright when it reads earlier than the date of the list + Singe ships -- the usual trouble on a board with no clock battery that + has not had network time. Behind it: + netTrustSystem(context) adds the machine's roots to a LuaSec context, and + every request shares one context instead of building one each time. + - Beta builds have a version of their own. CMake's project version has to be numbers, so the four it takes are used as major.minor.beta.which: 3.00.0.0 is v3.00 and 3.00.1.2 is v3.00b2, which is what the binary is named, what @@ -79,8 +106,16 @@ API Changes offered v3.00 the day it appears, and the minor is compared as a number, so 3.09 precedes 3.10. The launcher ranks the binaries beside it the same way. -- A game can be sent to the master service for review from Forge. P in the - editor exports, packs, and uploads it; an upload is not in the catalogue +- A Forge game says who made it. The game's form has credits fields -- + developer, publisher, year, platform, genre, creator, source, and + description -- and a release writes them into its games.dat, where the menu + shows them. Notes go into a CREDITS.txt beside the game, and licence + files named in the credits travel with it. A port of an existing game gets + its original author's credits filled in from the original's own games.dat + and script header. + +- A game can be sent to the master service for review from Forge. Shift+P + in the editor exports, packs, and uploads it; an upload is not in the catalogue until it has been reviewed, and Forge shows where each version of yours stands. Behind it: singePack(directory, database) packs a game from a script exactly as --pack does, and the network layer can send a request @@ -1125,7 +1160,17 @@ New Features - SIGTERM and SIGINT end all of Singe: the running game shuts down as usual, and the menu it came from does not come back. A second signal while that - is under way ends the process at once. + is under way ends the process at once, unless it comes within a second of + the first, since timeout and friends send the same request twice. + +- A script stuck in a loop no longer hangs Singe. Once a script has gone half + a second without returning to the engine, Singe keeps the window's events + moving for it, so the desktop doesn't mark the window frozen. If you then + close the window, or send SIGTERM or SIGINT, and the script still hasn't + returned after three seconds, Singe stops it and shuts down as usual, your + onShutdown included. The log shows where the script was when it was + stopped. A script that returns promptly never notices any of this, and one + catching errors with pcall can't hold the stop off. - --program traces where each frame's time went, every five seconds: per loop, the disc, the script, the scene, the GUIs, the overlay upload and the diff --git a/CMakeLists.txt b/CMakeLists.txt index b264afa98..70647daf7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -405,6 +405,8 @@ set(SINGE_SOURCE src/midi.h src/midiIo.c src/midiIo.h + src/netTrust.c + src/netTrust.h thirdparty/tinysoundfont/tsf.h thirdparty/tinysoundfont/tml.h src/render.c @@ -415,6 +417,8 @@ set(SINGE_SOURCE src/renderGpu.c src/scene.c src/scene.h + src/scriptWatch.c + src/scriptWatch.h src/singe.c src/singe.h src/stddclmr.h @@ -718,6 +722,7 @@ elseif(KANGAROO_OS STREQUAL "macos") -Wl,-framework,AudioToolbox -Wl,-framework,AVFoundation -Wl,-framework,Foundation + -Wl,-framework,Security -Wl,-weak_framework,GameController -Wl,-weak_framework,Metal -Wl,-weak_framework,QuartzCore diff --git a/assets/Backdrop.singe b/assets/Backdrop.singe index 09c5e0db5..ca280c0be 100644 --- a/assets/Backdrop.singe +++ b/assets/Backdrop.singe @@ -67,6 +67,12 @@ local LOOP_AT = 9.90 -- Where the recording of all this begins its loop -- 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. +local MEASURE_FROM = 2.0 -- Seconds: the backdrop is timed from here, the logo settled and flapping, +local MEASURE_TO = 4.0 -- to here, before the grid comes up, and the average sets this machine's + -- tier (PLAN.md section 57). The menu is the benchmark: it runs anyway. +local TIER_MS = { 2.5, 6.0, 20.0 } -- A frame of it at or under these milliseconds is tier 3, 2, or 1; + -- slower is 0. First guesses from this machine and the design's + -- reference hardware, to be set again on the real parts. -- The backdrop, which is the grid the menu sits on. Same numbers as testScripts/gridBackground. local CELL = 6.0 @@ -150,6 +156,8 @@ local gridLit = false -- Whether the grid has finished coming up and its local gridShown = false -- Whether the grid, the horizon and the sun are drawn at all yet. local backdropParts = {} -- Those nodes: hidden through the intro, shown as the grid comes up. local backdropFont = nil +local measuring = false -- Whether the backdrop is being timed right now. +local measured = false -- Whether this run has timed it, or has no intro to time. -- ===== The backdrop ========================================================================== -- @@ -497,6 +505,31 @@ function backdropFrame() emitterBurst(sparks, 130) end + -- The machine's tier, from how long this very scene takes to draw. + if not measured then + if (not measuring) and (t >= MEASURE_FROM) then + sceneMeasure(true) + measuring = true + elseif measuring and (t >= MEASURE_TO) then + local ms = sceneMeasure(false) + local tier = 0 + + measuring = false + measured = true + if ms then + for index, limit in ipairs(TIER_MS) do + if (tier == 0) and (ms <= limit) then + tier = #TIER_MS + 1 - index + end + end + if singeCapabilities().tier ~= tier then + singeSaveTier(tier) + end + debugPrint(string.format("Backdrop: %.2f ms a frame, tier %d", ms, tier)) + end + end + end + -- The fire's light dies over the first second and a half. lightSetIntensity(fireLight, 400 * (1 - span(t, 0.05, 1.5))) @@ -629,6 +662,8 @@ function backdropBegin(showIntro) backdropStarted = singeGetTicks() - (showIntro and 0 or (LIFT_TO * 1000)) blown = not showIntro + measured = not showIntro + measuring = false gridLit = false gridShown = false for _, part in ipairs(backdropParts) do diff --git a/assets/Menu.singe b/assets/Menu.singe index 55242a2f4..99d93ffee 100644 --- a/assets/Menu.singe +++ b/assets/Menu.singe @@ -71,10 +71,17 @@ function gameSelect(index) end --- Starts the selected game. The menu comes back from the top when it ends. +-- Starts the selected game. The menu comes back from the top when it ends. A game this machine +-- cannot run says why instead of failing part way in. function gameStart() + local game = GAME_LIST[GAME_SELECTED] + + if game.CANNOT_RUN then + menuNotice(game.MACHINE_FIT) + return + end SHUTDOWN_FROM_PUSH = true - scriptPush(GAME_LIST[GAME_SELECTED]) + scriptPush(game) end @@ -290,6 +297,35 @@ function unloadGameAssets() end +-- What this machine can run (PLAN.md section 57), asked once, and what each capability is called +-- when a game needs one the machine lacks. +local MACHINE = singeCapabilities() +local CAPABILITY_WORDS = { SCENE = "3D", GUI = "a GPU for its screens", SHADOWS = "shadows", POST = "post effects", SKIN = "skinned models", MORPH = "morph targets" } + + +-- How a game suits this machine: a line for the notice, or nil when it simply runs, and whether it +-- cannot run here at all. The list annotates; it does not hide. A game missing from a list gets +-- asked about, and one that says why it will not run does not. The Get Games tool asks it of the +-- catalogue's entries too, which carry the same REQUIRES and TIER. +function menuMachineFit(game) + local missing = {} + + for _, name in ipairs(type(game.REQUIRES) == "table" and game.REQUIRES or {}) do + if not MACHINE[name] then + missing[#missing + 1] = CAPABILITY_WORDS[name] or name + end + end + if #missing > 0 then + return "Needs " .. table.concat(missing, " and ") .. ", and this machine has none.", true + end + if MACHINE.tier and ((tonumber(game.TIER) or 0) > MACHINE.tier) then + return "Wants a faster machine than this one. It may run slowly.", false + end + + return nil, false +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 = {} @@ -309,6 +345,7 @@ local function loadGamesDat(source, container) end end end + value.MACHINE_FIT, value.CANNOT_RUN = menuMachineFit(value) table.insert(GAME_LIST, value) GAME_COUNT = GAME_COUNT + 1 end @@ -430,15 +467,18 @@ function menuNotice(text) end --- The notice for the selected game when nothing is in flight: the update on offer, or, failing --- that, the newer Singe on offer, or nothing. A download in progress keeps its own line. +-- The notice for the selected game when nothing is in flight: how it suits this machine, or the +-- update on offer, or, failing that, the newer Singe on offer, or nothing. A download in progress keeps its own line. function menuRefreshNotice() if MENU_UPDATING then return end - local update = GAME_SELECTED and menuUpdateFor(GAME_LIST[GAME_SELECTED]) or nil + local game = GAME_SELECTED and GAME_LIST[GAME_SELECTED] or nil + local update = game and menuUpdateFor(game) or nil - if update then + if game and game.MACHINE_FIT then + menuNotice(game.MACHINE_FIT) + elseif update then menuNotice(string.format("Version %d is available. Button 3 fetches it.", update.latest)) elseif MENU_RELEASE then menuNotice(string.format("Singe %s is available. SERVICE, then Engine Update.", MENU_RELEASE.version)) diff --git a/assets/MenuOverlay.singe b/assets/MenuOverlay.singe index f20acf367..9b5cd117a 100644 --- a/assets/MenuOverlay.singe +++ b/assets/MenuOverlay.singe @@ -330,6 +330,10 @@ end -- rather than handed to the menu as its disc, so the renderer that draws its own never opens it. MENU_RENDER.backdropBegin = function(showIntro) introDone = not showIntro + -- No GPU device, no 3D: tier 0, which is all this machine will ever measure (PLAN.md section 57). + if singeCapabilities().tier ~= 0 then + singeSaveTier(0) + end backdrop = videoLoad("Singe/menuBackground.mkv") videoPlay(backdrop) if not showIntro then diff --git a/assets/Net.singe b/assets/Net.singe index 4785290d2..a42dc9ede 100644 --- a/assets/Net.singe +++ b/assets/Net.singe @@ -40,10 +40,13 @@ -- 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. +-- Without a pin the chain is verified against the union of two CA lists: Mozilla's, which the +-- engine extracts as Singe/cacerts.pem beside the other support files, and the machine's own +-- (the distribution's file on Linux, the root store on Windows, the system anchors on macOS), +-- which netTrustSystem adds, with a cacerts.local.pem from the data directory for a self-hosted +-- server's CA. A connection verifies when either list knows the server's root, so a cabinet +-- whose bundle has gone stale can still reach the release that brings a new one, as long as the +-- machine's own list is current (PLAN.md section 61). local socket = require("socket") @@ -58,11 +61,11 @@ NET_REDIRECT_MAX = 5 -- 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. +NET_CA_SHIPPED = "Singe/cacerts.pem" -- The list Singe carries; the machine's own is added to it. local active = {} local nextId = 1 +local trust = nil -- The one client context every request shares, and what it was built from. -- The SHA-256 of a peer's public key, in the form the pins are written in. @@ -159,33 +162,100 @@ local function buildRequest(request) end --- The CA list a connection without a pin is verified against. -local function caFile() - local lfs = require("lfs") +-- The shipped bundle's own name for its version, from its header, so a failure can say how old it +-- is without a second copy of the date to keep in step. +local function bundleVersion() + local file = io.open(NET_CA_SHIPPED, "r") + local line = nil + local seen = nil - if lfs.attributes(NET_CA_SYSTEM, "mode") == "file" then - return NET_CA_SYSTEM + if not file then + return "missing" end + for _ = 1, 5 do + line = file:read("l") + if not line then + break + end + seen = seen or line:match("ca%-certificates (%d+)") + end + file:close() - return NET_CA_SHIPPED + return seen or "of unknown date" end -local function startTls(request) +-- What the clock has to do with a failed verification. Certificates are checked against the date, +-- and a board with no battery-backed clock that has not had network time reads a date long past: +-- when the clock reads earlier than the shipped bundle was made, it is certainly wrong, whatever +-- OpenSSL called the failure. Otherwise a "not valid yet" certificate still points at the clock. +local function clockNote(err) + local now = os.time() + local reads = os.date("%Y-%m-%d %H:%M", now) + local y, m, d = bundleVersion():match("^(%d%d%d%d)(%d%d)(%d%d)$") + + if y and (now < os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d), hour = 0 })) then + return ". This machine's clock reads " .. reads .. ", before the certificate list Singe ships was made (" .. y .. "-" .. m .. "-" .. d .. "): set the date and time" + end + if tostring(err):find("not yet valid", 1, true) then + return ". The certificate is not valid yet by this machine's clock, which reads " .. reads .. ": check the date and time" + end + + return "" +end + + +-- The client context, built once: the shipped bundle, then the machine's own roots and the local +-- file on top. Rebuilding it per request parsed the whole bundle every connection. +local function trustContext() + if trust then + return trust.context + end 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 + -- what decides. Without one, the lists are asked -- and if they have nothing to say, the -- request fails rather than proceeding unverified. verify = (#NET_PINS > 0) and "none" or "peer", - cafile = caFile(), + cafile = NET_CA_SHIPPED, } - local wrapped, err = ssl.wrap(request.socket, params) + local context, err = ssl.newcontext(params) + + if not context then + return nil, err + end + local _, report = netTrustSystem(context) + + trust = { context = context, report = report } + + return context +end + + +-- What a failed verification was checked against, for the message a person reads. +local function trustChecked() + local lists = { "shipped " .. NET_CA_SHIPPED .. " (" .. bundleVersion() .. ")" } + + for line in ((trust and trust.report) or ""):gmatch("[^\n]+") do + lists[#lists + 1] = line + end + + return table.concat(lists, "; ") +end + + +local function startTls(request) + local context, err = trustContext() + + if not context then + return fail(request, "TLS setup failed: " .. tostring(err)) + end + local wrapped, why = ssl.wrap(request.socket, context) if not wrapped then - return fail(request, "TLS setup failed: " .. tostring(err)) + return fail(request, "TLS setup failed: " .. tostring(why)) end wrapped:settimeout(0) if wrapped.sni then @@ -218,7 +288,7 @@ local function pumpHandshake(request) return end if err ~= "wantread" and err ~= "wantwrite" and err ~= "timeout" then - fail(request, "TLS handshake failed: " .. tostring(err)) + fail(request, "TLS handshake failed: " .. tostring(err) .. ". Checked: " .. trustChecked() .. clockNote(err) .. ".") end end diff --git a/assets/Tools.singe b/assets/Tools.singe index 14a6a7744..4823d283c 100644 --- a/assets/Tools.singe +++ b/assets/Tools.singe @@ -1429,6 +1429,13 @@ local function shopShow() elseif held ~= nil then state = (held >= game.version) and "installed" or ("update to " .. game.version) end + -- How it suits this machine, from the REQUIRES and TIER the service took from the game itself. + local fit, cannot = menuMachineFit({ REQUIRES = game.requires, TIER = game.tier }) + if cannot then + state = state .. ", will not run here" + elseif fit then + state = state .. ", may run slowly" + end rows[#rows + 1] = toolsItem(game.title, index == shopSelected, state) end if #shopGames == 0 then diff --git a/assets/cacerts.pem b/assets/cacerts.pem index 4cde36ec9..f47551c77 100644 --- a/assets/cacerts.pem +++ b/assets/cacerts.pem @@ -1,7 +1,7 @@ -# Root certificates the master service is verified against on platforms with no trust store -# LuaSec can read (Windows, macOS, the handhelds). Mozilla's CA list, as packaged by Debian -# ca-certificates 20260601, taken 2026-09-13. Distributed under the Mozilla Public License 2.0. -# Refresh from /etc/ssl/certs/ca-certificates.crt on a current Linux; see Net.singe. +# Root certificates Singe trusts alongside the machine's own (Net.singe adds those through +# netTrustSystem), so a machine with no current list of its own still verifies. Mozilla's CA +# list, as packaged by Debian ca-certificates 20260601, taken 2026-09-13. Distributed under the +# Mozilla Public License 2.0. Refresh from /etc/ssl/certs/ca-certificates.crt on a current Linux. -----BEGIN CERTIFICATE----- MIIH0zCCBbugAwIBAgIIXsO3pkN/pOAwDQYJKoZIhvcNAQEFBQAwQjESMBAGA1UE AwwJQUNDVlJBSVoxMRAwDgYDVQQLDAdQS0lBQ0NWMQ0wCwYDVQQKDARBQ0NWMQsw diff --git a/docs/Forge.adoc b/docs/Forge.adoc index 1c0cae8bb..0ba05ff8d 100644 --- a/docs/Forge.adoc +++ b/docs/Forge.adoc @@ -1311,44 +1311,40 @@ gun wants none drawn; `discPlay` and `discPause` let the disc go and hold it, a still behind a title; `pointerX(1)` and `pointerY(1)` in an expression say where the pointer is. The light sensor way of judging a shot over video -- the picture under the gun agreeing with a sensor spot across -two frames -- is a `lightSensor` behaviour those five games carry in a -vocabulary of their own (see <>), not one of Forge's. +two frames -- is Forge's `lightSensor` behaviour, with `pictureAt` as the +condition that reads the picture. -*A QTE game from the KarisFramework.* The `qte` behaviour plays a -laserdisc quick-time-event game as that framework does -- attract loop, -credits, levels of scenes with moves to answer in a window of frames, death -clips, continues, the dips -- from the framework's own tables under -`qte` in the description, which `util/forgePortKaris.lua` writes from a -game's script. The game's script and its `Script/addons.singe` run inside -the behaviour, so a game keeps its own scoring and level order. The -seventeen games of the test library and eleven of the Hypseus library are -ported this way and play as their originals do (FORGE.md section 14), and -so is the one game on the MazescaterFramework -- that framework -with compound moves (sequences of two or three inputs, circles from any -start, mashes of a direction or a button, a direction with the second or -third button), which the behaviour judges from tables of its own once the -converter has marked the description with the framework's name. The -`kimmy` behaviour plays the same author's 2024 engine, whose moves are -judged by what is held together -- combos, mashes in three variants, -holds, sequences, paths, timed and yes/no branches -- with tilt, a life -bar, a die-and-retry count, a new-game menu, and a level select; the same -converter writes its description, and the seven Kimmy games are ported -with it. The `rdg` behaviour plays the map-mode lineage those frameworks -grew from (RDG's LUA SINGE 1.0 and 1.1): a game of that kind keeps its -levels, moves, death clips, order, and map as functions of its own -main.singe, and the behaviour runs those files as they are, beside the -description, over its own loop -- levels of segments, a move an input or -a mash or a pair of inputs in a window, a shot at a zone of the picture -where the game carries one, a map between levels -- with the copies' -drift (the later generation's level order, game type, and state numbers) -gated by what the game's script declares, and what a title does without -declaring it named in a `tweaks` table the converter writes. The -`timegal` behaviour plays one of the same author's remakes, a compact -loop of its own (checkpoints, a time-stop choice of three, a tally by -deaths, a life per quota, a level order by tiers), over the game's own -files in the same way; and the `sdq` behaviour plays one game that is -the one hybrid of map-mode data under the Karis framework's judging, -with its difficulty select, level clear, and trophies. +*A quick-time-event game.* The `qte` behaviour plays a laserdisc +quick-time-event game from tables under `qte` in the description: an +attract loop, credits, levels of scenes with moves to answer, death clips, +continues, and the game's settings. The port converter writes those tables +from an existing game's script, and the game's own script and add-ons run +inside the behaviour, so a port keeps its own scoring and level order. + +`qte.options.style` picks how the game plays: + +* `windows`, the default: each move is answered in a window of frames. + Moves can be compound -- sequences of two or three inputs, circles from + any start, mashes of a direction or a button, a direction with a second + button. +* `held`: moves are judged by what's held together (combos, three kinds of + mash, holds, sequences, paths, timed and yes/no branches), with tilt, a + life bar, a die-and-retry count, a new-game menu, and a level select. +* `segments`: levels of segments, a move being an input, a mash, or a pair + of inputs in a window, with a map between levels, running the game's own + level, move, death-clip, and order functions as they are. +* `checkpoints`: segments with checkpoints, a time-stop choice of three, a + tally by deaths, and a life per quota. +* `trophies`: segmented levels judged the windows way, with a difficulty + select, a level-clear screen, and trophies. + +Where two styles differ on the same screen (whether a game over after a +continue plays on from the continue clip, whether a death after the hints +counts as a wrong move, and so on), the difference is a named option in +`qte.options`, and each style sets its own default. A description can ask +for either behaviour whatever its style. Each port is compared with its +original move for move, by the traces both print on the same inputs; +FORGE.md section 14 has how, and where each title stands. *A hand-written game.* A game that was a program of its own rather than a framework's tables -- a set of light gun titles, a typing edition of one @@ -1885,6 +1881,31 @@ for every named file there first, so the release runs wherever it is put. `--pack` turns that directory into a `.game` like any other, and it runs on a machine that has never had Forge on it. +[#credits] +==== Credits + +Say who made the game. The game's form (nothing selected, entities panel) has a +row of `credits` fields: developer, publisher, year, platform, genre, creator, +source, and description. They land in the release's `games.dat` under the keys +the menu shows in its details panel, so whoever made the game is named +wherever it is played. Two more fields hold what the others have no room for: +`credits.notes`, lines separated by `|` that go into a `CREDITS.txt` beside the +game, and `credits.licenses`, files that travel with the release like any other +file the description names. + +[source,lua] +---- +credits = { + developer = "Somebody Else", + year = 1983, + notes = { "Original game: Somebody Else", "Forge version: You" }, + licenses = { "COPYING.txt" } +}, +---- + +If your game is based on someone else's, this is where they get their due, and +if their licence says it has to ship with the game, `licenses` is how it does. + `Shift+P` goes one step further: it exports, packs, and sends the package to the master service for review, in one press. It is not in the catalogue until it has been reviewed, and Forge says which version number it was given. Press diff --git a/docs/ForgeVocabulary.adoc b/docs/ForgeVocabulary.adoc index be88140b8..ab08b5cef 100644 --- a/docs/ForgeVocabulary.adoc +++ b/docs/ForgeVocabulary.adoc @@ -27,7 +27,7 @@ | -- | `parser` -| A text parser, the Sierra way: a prompt the player types at, and words the game knows. What is typed raises the said event. +| A text parser: a prompt the player types at, and words the game knows. What is typed raises the said event. | `ignore` (table), `prompt` (string), `words` (table) | `scene3d` @@ -78,6 +78,10 @@ | A steady stream of particles from the instance: a fire, smoke, a thruster, rain. Colour, size, speed, and life as an emitter takes them. | `b` (number), `g` (number), `gravity` (number), `life` (number), `r` (number), `rate` (number), `size` (number), `speed` (number), `spread` (number) +| `qteHud` +| The HUD of a quick-time-event game: what its qte behaviour's state says a player sees (the score and the record, the lives or the life bar, and the rest), drawn from the game's own pictures and font as the game lays them out. +| -- + | `sprite` | An image, centred on the instance -- or standing on it, with anchor feet. frames splits a sheet into that many columns; the var frame picks one. The var angle turns it, starting from the entity's rz. faces says which way the art looks (right unless said, or none): while the var facing is the other way the image is mirrored, so art drawn one way walks both. The var copies draws it that many times in a row, step pixels apart (a row of bullets, of hearts): negative steps go left. | `anchor` (feet), `faces` (right, left, none), `file` (file), `frames` (number), `step` (number) @@ -107,7 +111,7 @@ | `bounce` (number), `buoyancy` (number), `friction` (number), `mass` (number), `shape` (box, sphere) | `branching` -| A branching laserdisc game: a track of branches, each a disc frame window, the move it wants, and where the disc goes on success and on failure. A branch with mash wants the move that many times inside the window; one with hold wants it held that many frames. Raises branchOpen, branchTaken, and branchMissed. +| Branch windows over the disc: a track of branches, each a disc frame window, the move it wants, and where the disc goes on success and on failure. A branch with mash wants the move that many times inside the window; one with hold wants it held that many frames. Raises branchOpen, branchTaken, and branchMissed. | `track` (track) | `camera` @@ -150,10 +154,6 @@ | Reads a player's keys into the vars dx and dy (-1 to 1) and fire, so rules and movers read intent rather than hardware. | `down` (scancode), `fire` (scancode), `left` (scancode), `player` (number), `right` (scancode), `up` (scancode) -| `kimmy` -| Plays a laserdisc quick-time-event game the Kimmy Script Engine way (Karis's 2024 framework), from the framework's own tables under game.qte: the attract loop, a new-game menu, levels of scenes with moves judged by what is held -- combos, mashes, holds, sequences, paths, timed and yes/no branches -- tilt, the life bar, the die-and-retry count, and the game's own add-ons. Written by util/forgePortKaris.lua from a game's script. -| -- - | `lightSensor` | Judges a shot by the picture rather than by a hitbox, the way a home laserdisc gun does: a spot on the picture (x, y) flashes bright or dark on the frames a target is shown, and a shot is good when the picture under the gun changes the way the spot does over the two frames after the trigger. A shot into the spot's own corner (left, top and beyond) is not judged, so the spot cannot be shot. Raises fire on the trigger, then hit or miss. For a game whose disc carries the targets in its own picture; a game with hitboxes wants gun and target instead. | `high` (number), `left` (number), `low` (number), `player` (number), `top` (number), `trigger` (switch), `x` (number), `y` (number) @@ -179,21 +179,13 @@ | `aim` (boolean), `life` (number), `vx` (number), `vy` (number), `vz` (number) | `qte` -| Plays a laserdisc quick-time-event game the KarisFramework way (or the MazescaterFramework's, with its compound moves), from the framework's own tables under game.qte: attract, credits, levels of scenes with moves to answer in a window of frames, death clips, continues, the dips, and the game's own add-ons. Written by util/forgePortKaris.lua from a game's script. +| Plays a laserdisc quick-time-event game from the tables under game.qte: an attract loop, credits, levels of scenes with moves to answer in a window of frames, death clips, continues, the settings, and the game's own add-ons. qte.options.style says how its moves are judged and its levels run: windows (the default: a move answered in a window, compound moves too), held (moves judged by what is held together: combos, mashes, holds, sequences, paths, timed and yes/no branches, tilt, a life bar, die and retry, a new-game menu, a level select), segments (levels of segments, a map between them, the game's own level functions run as they are), checkpoints (checkpoints, a time-stop choice of three, a tally by deaths, a life per quota), or trophies (segmented levels judged the windows way, a difficulty select, a level-clear screen, trophies). The port converter writes the tables from an existing game's script. | -- | `racer` | Drives a vehicle round a track of points on its own: steering toward the next, throttling by how straight the road is. | `reach` (number), `throttle` (number), `track` (track) -| `rdg` -| Plays a laserdisc quick-time-event game of RDG's map-mode lineage (the ancestor of the Karis framework): levels of segments, a move an input or a mash or a pair of inputs in a window, a map between levels. The game's own files are loaded and its level, move, death-clip, order, and map functions run as they are; the loop is this. Written by util/forgePortKaris.lua from a game's script. -| -- - -| `sdq` -| Plays one hybrid map-mode game: map-mode levels, scenes, death clips, order, and map from the game's own files, judged as the Karis 3.31c framework judges, with its difficulty select, level clear, finish, and trophies. Written by util/forgePortKaris.lua from the game's script. -| -- - | `seek` | Moves straight toward a target -- an entity's id, or "camera" -- and raises arrived within stopAt of it. | `fly` (boolean), `speed` (number), `stopAt` (number), `target` (string) @@ -230,10 +222,6 @@ | A body pushed along its nose by the var dy and turned by dx: a hovercraft, a ship, a plane without lift. | `force` (number), `turn` (number) -| `timegal` -| Plays one of RDG2010's remakes: levels of moves from a difficulty array (mirrored by a random draw), checkpoints, a time-stop choice of three, a tally by deaths, a life per quota, and a level order by tiers, at random, or in sequence. The game's own files are loaded and its data and order functions run as they are; the loop is this. Written by util/forgePortKaris.lua from the game's script. -| -- - | `timer` | Raises the timer event with its name after some seconds, or every so many. start false waits for timerStart. | `after` (number), `every` (number), `name` (string), `start` (boolean) @@ -505,7 +493,7 @@ | | `die` -| The Sierra death: a message, then the game starts over. +| A death: a message, then the game starts over. | `text` (expression) | yes diff --git a/docs/Manual.adoc b/docs/Manual.adoc index a3fe85a83..3b1197af7 100644 --- a/docs/Manual.adoc +++ b/docs/Manual.adoc @@ -183,6 +183,18 @@ showing, `SHOW_INTRO` is false when it is returning from a game so the intro is not sat through twice, and `MENU_OVERLAY` chooses a renderer (see <>). +Not every machine runs every game. A 3D game on a box with no 3D, or a heavy +game on a small one, would fail or crawl, so the menu tells you first. When you +select a game this machine cannot run, the line under its details says what it +needs ("Needs 3D, and this machine has none."), and starting it just repeats +that instead of failing part way in. A game that wants more machine than this +one gets "It may run slowly." and starts anyway. Nothing is hidden. The menu +works out how fast this machine is the first time its intro plays, by timing +its own backdrop, and keeps the answer in `machine.cfg`; it never asks you to +run a benchmark. Games say what they need in their `games.dat` +(xref:Reference.adoc#gamesdat[REQUIRES and TIER]), and the Get Games tool marks +the catalogue the same way. + [#menurenderers] ==== The Two Renderers @@ -244,6 +256,16 @@ the menu made. See xref:Reference.adoc#onlinescores[Online high scores] for the blocks a frame, because Lua in Singe is cooperative and a large download would otherwise look like a hang. +Singe checks the server's certificate against two lists at once: the one it +ships with and the one your machine keeps. Either one vouching for the server +is enough, so an old copy of Singe can still reach the update that replaces it +as long as your system keeps its own list current. Running your own service +with a certificate from your own authority? Put that authority's certificate in +`cacerts.local.pem` in the data folder. Singe never writes that file, so it +survives updates. If a connection is refused, the message says which lists were +checked, and a certificate that is "not yet valid" almost always means the +machine's clock is wrong. + [#servicetools] ==== The Service Tools diff --git a/docs/Reference.adoc b/docs/Reference.adoc index 023d09136..0c35b8b3b 100644 --- a/docs/Reference.adoc +++ b/docs/Reference.adoc @@ -585,7 +585,7 @@ GAMES = { The keys `SCRIPT`, `VIDEO`, `CANVAS_X`, `CANVAS_Y`, `STRETCH`, `NO_MOUSE`, `RESOLUTION_X`, `RESOLUTION_Y`, `SINDEN_GUN`, `AUDIO_TRACK`, -`AUDIO_DELAY`, `AUDIO_SUFFIX`, `GAME_ID`, and `LEGACY_SPRITE_ARGS` are read by the engine when the menu (or your own +`AUDIO_DELAY`, `AUDIO_SUFFIX`, `GAME_ID`, `LEGACY_SPRITE_ARGS`, `REQUIRES`, and `TIER` are read by the engine when the menu (or your own script, through `scriptExecute` / `scriptPush`) launches the entry; they override the command line. The `VIDEO` line is the disc: an entry that names one is a laserdisc game, and an entry that leaves it out or blank runs @@ -597,6 +597,24 @@ release whose other languages ship as separate files, the same thing with the old sprite argument order (see <>). The remaining keys are read by the menu for display. +`REQUIRES` says what the game needs of a machine, as a list of names: +`"SCENE"` (the 3D scene), `"GUI"` (GUI documents), `"SHADOWS"`, `"POST"` +(bloom and the other effects that want float render targets), `"SKIN"` +(skinned models), and `"MORPH"` (morph targets). No `REQUIRES` means 2D. The +menu marks a game this machine cannot run and says why instead of starting it, +and you rarely write the list yourself: the engine notices what a game uses as +it runs, says in the trace when the game uses something `REQUIRES` does not +list, and `--pack` writes the list into an entry that has none. `TIER` (0 to +3) is how much machine the game wants; the menu measures this machine's tier +from its own backdrop and marks a game that wants more as one that may run +slowly. See <>. + +[source,lua] +---- +REQUIRES = { "SCENE", "SHADOWS" }, +TIER = 1, +---- + A script launched directly, by name or by its directory, gets the same treatment: Singe looks for a `games.dat` in the script's directory and up to three directories above it, and if an entry's `SCRIPT` names the launched @@ -5339,7 +5357,7 @@ The picture each particle wears: a loaded sprite, or none (one argument, or `nil [source,lua] ---- -- Snow: a sheet of six flake shapes, one picked at random per particle. -flakes = spriteLoadFrames(DIR .. "Sprites/Flakes", 6) +flakes = spriteLoadFrames(6, DIR .. "Sprites/flakes.png") snow = emitterNew() emitterSetTexture(snow, flakes) emitterSetFrames(snow, 0, 5) @@ -9928,6 +9946,49 @@ navSave(nav, "level1.nav") debugPrint("Navigation mesh baked and saved.") ---- +[#net] +=== Net + +What the engine adds underneath `Singe/Net.singe`, the menu's HTTP client. +Most games never touch it: posting a score or fetching a catalogue goes +through `Net.singe`, which calls this for you. + +[#nettrustsystem] +==== netTrustSystem + +[source,text] +---- +lists, report = netTrustSystem(context) +---- + +Adds the machine's own trusted root certificates to a LuaSec client context, +on top of whatever the context already holds (normally the shipped +`Singe/cacerts.pem`). A connection made with the context then verifies when +either list knows the server's root. The machine's list is, in order: the file +or directory named by `SSL_CERT_FILE` or `SSL_CERT_DIR` when set; otherwise the +distribution's bundle on Linux (the first of the usual six places that +exists), the root system store on Windows, or the system anchors on macOS. +`cacerts.local.pem` in the data folder is added as well when it exists; the +engine never writes it. + +`lists` is how many lists added anything. `report` names what was consulted, +one `what path` per line (`what` is `system` or `local`), for an error message +a person can act on. + +*Since:* 3.00. +*See also:* <> + +.Example +[source,lua] +---- +-- One context for every request: the shipped bundle, then this machine's roots on top. +local ssl = require("ssl") +local context = ssl.newcontext({ mode = "client", protocol = "any", verify = "peer", cafile = "Singe/cacerts.pem" }) +local lists, report = netTrustSystem(context) + +debugPrint("Trusting the shipped bundle and " .. lists .. " more:\n" .. report) +---- + [#node] === Node @@ -12716,6 +12777,39 @@ function onOverlayUpdate() end ---- +[#scenemeasure] +==== sceneMeasure + +[source,text] +---- +sceneMeasure(on) +ms = sceneMeasure(false) +---- + +Times the 3D scene. `sceneMeasure(true)` starts: every frame after it waits for +the GPU to finish the scene and adds that time up, so what is measured is the +work rather than the display's pacing. `sceneMeasure(false)` stops and returns +the milliseconds a frame averaged, or `nil` when no frame was drawn. The wait +costs frame rate, so time a few seconds, not a whole game. + +*Returns:* nothing when starting; a number or `nil` when stopping. + +*Since:* 3.00. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- Two seconds of the title screen tell whether the full effects fit. +sceneMeasure(true) +-- ... two seconds later: +local ms = sceneMeasure(false) + +if ms and (ms > 8) then + sceneSetBloom(1.0, 0.0) +end +---- + [#sceneprobegui] ==== sceneProbeGui @@ -13379,6 +13473,38 @@ if utilSha256(bytes) ~= expected then end ---- +[#singecapabilities] +==== singeCapabilities + +[source,text] +---- +capabilities = singeCapabilities() +---- + +What this machine can run. The table has one entry per capability name a +`games.dat` `REQUIRES` list can use (`SCENE`, `GUI`, `SHADOWS`, `POST`, `SKIN`, +`MORPH`), each `true` or `false`, and `tier`: the machine's measured +performance class from 0 (no 3D) to 3 (a desktop GPU), or `nil` before the menu +has measured it. A game asks so it can do without something rather than be +refused for it. + +*Returns:* a table. + +*Since:* 3.00. +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- Shadows where the machine has them, a plain sun where it does not. +local machine = singeCapabilities() + +lightSetShadow(sun, machine.SHADOWS) +if (machine.tier or 0) < 2 then + sceneSetBloom(1.0, 0.0) +end +---- + [#singegetaudiocalibration] ==== singeGetAudioCalibration @@ -13888,6 +14014,36 @@ function onInputPressed(input) end ---- + +[#singesavetier] +==== singeSaveTier + +[source,text] +---- +singeSaveTier(tier) +---- + +Keeps this machine's performance class, 0 to 3, in `machine.cfg` beside the +audio delay and the geometry, where <> +reports it and the menu compares it with each game's `TIER`. The bundled menu +measures the tier itself, from how long its 3D backdrop takes to draw +(<>), and saves it only when it changes; nothing +asks the player. + +*Returns:* nothing. + +*Since:* 3.00. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- A machine with no 3D at all is tier 0. +if not singeHasGpu() then + singeSaveTier(0) +end +---- + [#singescreenshot] ==== singeScreenshot diff --git a/docs/lessons/19-text.adoc b/docs/lessons/19-text.adoc index 031c369ef..a5c817eff 100644 --- a/docs/lessons/19-text.adoc +++ b/docs/lessons/19-text.adoc @@ -9,9 +9,9 @@ It is perfect for a frame counter. It is wrong for a title, a score, a menu, or anything a player is meant to look at rather than read past. This lesson loads a real font off disk, prints with it, colours it, measures it -so you can centre things, and shows you the one performance trap that catches -everybody. At the end you will have a title screen that looks deliberate -instead of accidental. +so you can centre things, and shows you what printing text actually costs. At +the end you will have a title screen that looks deliberate instead of +accidental. === Set the Overlay to a Size You Can Think In @@ -181,41 +181,40 @@ above `spriteDraw` instead does nothing at all, because a sprite is drawn with its own pixels and is never tinted. If your carefully coloured title comes out white, this is why. -=== The Trap: fontPrint Is Not Free +=== What Printing Costs -NOTE: Rendering text to a sprite for performance is deprecated. Singe 3.00's -`fontPrint` keeps every string it renders and only copies it again while the -string, font, quality, and colours stay the same, so printing a title every -frame now costs about what drawing a sprite of it does. The trap below is the -one earlier versions set, and the pattern still works; new games can just -`fontPrint`, and keep `fontToSprite` for text that has to be a sprite -- to be -measured, scaled, or turned. +The first time `fontPrint` sees a string, it does real work: it rasterizes +every letter from the font's outlines and builds an image of the whole string. +Then it keeps that image. The next time you print the same string, with the +same font, quality, and colours, it skips all of that and copies the image it +already has, which is about what drawing a sprite costs. A string you stop +printing is let go a second later, so nothing piles up. -Here is the thing the reference says plainly and everyone reads past. +That means the obvious way to write a title screen is also the fast one. +Printing `"ASTEROID PATROL"` sixty times a second renders it once. A score that +changes twice a second renders twice a second, on its own, with no bookkeeping +from you. -`fontPrint` does not draw a picture it made earlier. Every single call -rasterizes the whole string from the font outlines, builds an image of it, -copies that image onto the overlay, and throws it away. Then, one sixtieth of a -second later, it does all of that again. For a title that has not changed since -the game started, you are re-drawing the same letters from scratch sixty times -a second, forever. +The one case that costs is text that is *different* every frame -- a timer in +milliseconds, a frame counter -- because every frame is a string `fontPrint` +has never seen. On a desktop it does not matter. On a Raspberry Pi or a +handheld, a screenful of it will. The cure is to change the text less often: +a timer in whole seconds is a new string once a second, not sixty times. -On a desktop with three lines of text you will never notice. With a screen full -of text, or on a Raspberry Pi, or in a cabinet, you will notice a great deal. +So why did this lesson use `fontToSprite` at all? Because a sprite can do +things printed text cannot: -The fix is the call you just met. `fontToSprite` does that work *once*. -`spriteDraw` afterwards is a plain copy, which is what the hardware is for. So -the rule is short: +* *It can be measured*, which is the only way to centre text or line it up + against the right hand side. +* *It can be scaled and turned* with the `sprite*` calls from lesson nine. +* *It can outlive its font*, which matters in a moment. -* Text that never changes -- titles, labels, instructions, menu entries -- - `fontToSprite` once at startup, `spriteDraw` every frame. -* Text that changes every frame and nobody is staring at -- a debug readout, a - frame number -- `fontPrint` is fine. -* Text that changes sometimes -- a score, a name, a timer in whole seconds -- - render a new sprite when it changes, and unload the old one. +Use `fontPrint` for text, and `fontToSprite` when you need one of those. -That last case is worth writing out, because "unload the old one" is easy to -forget and forgetting it leaks a sprite per change: +The score in this lesson needs one of them: it sits against the right hand +margin, so it has to be measured, so it has to be a sprite. And a sprite of a +score is only right until the score changes. When it does, render a new sprite +and unload the old one -- forgetting that leaks a sprite per change: [source,lua] ---- @@ -240,9 +239,6 @@ tell whether it is still right: end ---- -A score changes perhaps twice a second. This renders twice a second instead of -sixty times, and the code to do it is nine lines. - `string.format("%06d", score)` is plain Lua, not Singe. It turns a number into a string, and `%06d` means "as a whole number, at least six digits, padded with zeros", which is why arcade scores have leading zeros. Change it to `%d` and @@ -374,10 +370,10 @@ function onShutdown() end ---- -Run it and hold the fire button. The title sits centred and never re-renders, -the score climbs and re-renders only when it changes, the hint blinks, and the -clock at the bottom is the one thing honestly drawn with `fontPrint` sixty -times a second. +Run it and hold the fire button. The title and the hint sit centred, the score +climbs against the right margin and is re-rendered only when it changes, the +hint blinks, and the label and the clock are printed with `fontPrint` every +frame -- the clock in whole seconds, so it is a new string only once a second. === What Just Happened @@ -448,8 +444,14 @@ and it removes a whole category of puzzled evening. measured. Get it against the right margin anyway. There is only one way, and finding it is the point. . *Take out the `scoreShown` check*, so `renderScore()` runs every frame - instead. It will look identical. Leave it running for a minute and watch the - memory your game is using. + instead. It will look identical, but it now renders the score sixty times a + second instead of when it changes -- exactly the work `fontPrint` saves you + when you let it. Then take out the `spriteUnload` in `renderScore` as well, + leave it running for a minute, and watch the memory your game is using. +. *Print a clock in milliseconds.* Change the clock line to print + `singeGetTicks()` without the `// 1000`. It still works; it is now a string + `fontPrint` has never seen, every frame. On a slow machine, that is the kind + of text that costs. . *Use your own font.* Find a `.ttf` on your machine, copy it beside your script, and load it with `DIR .. "thatfont.ttf"`. Some fonts look terrible at 18 point and fine at 48. That is the font's fault, not yours. @@ -484,9 +486,9 @@ thing, several hundred lines away from the `fontUnload` that caused it. * `fontQuality` picks how letters are rasterized, and `FONT_QUALITY_BLENDED` is the usual answer. * `fontPrint` keeps what it renders, so printing the same text every frame is - cheap. + cheap; text that is different every frame is what costs. * `fontToSprite` renders into a sprite, which `spriteGetWidth` measures and the - `sprite*` calls can scale and turn. Using it only for speed is deprecated. + `sprite*` calls can scale and turn. Use it for that, not for speed. * Measuring is how you centre or right align text, because nothing else can tell you how wide a string will be. * A sprite's colour is fixed when it is made, not when it is drawn. diff --git a/forge/Author.singe b/forge/Author.singe index 7bbca7fa8..4046ff035 100644 --- a/forge/Author.singe +++ b/forge/Author.singe @@ -27,7 +27,7 @@ -- -- A game made with Forge is not interpreted: Forge/AuthorCompile.singe turns its description into -- ordinary Lua, and this file is the library that Lua calls. Rules become real functions, so --- nothing walks a table every frame, and the result can be opened in ZeroBrane and edited by hand +-- nothing walks a table every frame, and the result can be opened in a Lua editor and edited by hand -- like any other game. -- -- The nouns (FORGE.md section 3): @@ -80,6 +80,7 @@ AUTHOR_ROOM = nil -- The current room's table. AUTHOR_STARTED = 0 -- singeGetTicks() when the game began. AUTHOR_CONTROLS = true -- False while a cut-scene has the controls. AUTHOR_FONT = nil +AUTHOR_FONT_FILE = "Singe/FreeSansBold.ttf" -- Forge's own font, for text a game gives no font AUTHOR_FONT_POINTS = 18 local GRAVITY_DEFAULT = 1400 -- Overlay units a second squared; a 480 tall screen wants about this. @@ -612,7 +613,7 @@ AUTHOR.layers.bezel = { } AUTHOR.layers.parser = { - help = "A text parser, the Sierra way: a prompt the player types at, and words the game knows. What is typed raises the said event.", + help = "A text parser: a prompt the player types at, and words the game knows. What is typed raises the said event.", params = { prompt = "string", words = "table", ignore = "table" }, begin = function(layer) parserLayer = layer @@ -807,6 +808,18 @@ AUTHOR.looks.none = { } +AUTHOR.looks.qteHud = { + help = "The HUD of a quick-time-event game: what its qte behaviour's state says a player sees (the score and the record, the lives or the life bar, and the rest), drawn from the game's own pictures and font as the game lays them out.", + params = {}, + size = function() return 0, 0, 0 end, + draw = function(instance) + if instance.q then + authorQteHud(instance.q) + end + end +} + + local function modelFor(file) if models[file] == nil then models[file] = modelLoad(authorFile(file)) @@ -1874,7 +1887,7 @@ AUTHOR.behaviours.turret = { } AUTHOR.behaviours.branching = { - help = "Dragon's Lair: a track of branches, each a disc frame window, the move it wants, and where the disc goes on success and on failure. A branch with mash wants the move that many times inside the window; one with hold wants it held that many frames. Raises branchOpen, branchTaken, and branchMissed.", + help = "Branch windows over the disc: a track of branches, each a disc frame window, the move it wants, and where the disc goes on success and on failure. A branch with mash wants the move that many times inside the window; one with hold wants it held that many frames. Raises branchOpen, branchTaken, and branchMissed.", params = { track = "track" }, attach = function(instance, params) instance.branching = params @@ -2527,7 +2540,7 @@ AUTHOR.actions.loadGame = { } AUTHOR.actions.die = { - help = "The Sierra death: a message, then the game starts over.", + help = "A death: a message, then the game starts over.", params = { text = "expression" }, waits = true, emit = function(p) return "authorDie(" .. p.text .. ")" end @@ -4598,7 +4611,7 @@ function authorTrackBoxAt(track, at) local before, after -- A stepped track keeps a box only on the frames it keys: hitboxes read off a film frame - -- by frame (the American Laser Games files) are exact there and absent between. + -- by frame (the gun games' hitmaps) are exact there and absent between. if track.step then for _, key in ipairs(keys) do if key.at == at then @@ -4862,7 +4875,7 @@ function authorBegin(game) -- A font, because the text look draws with fontPrint and fontPrint ends the game when none is -- selected. A game that wants its own calls fontSelect afterwards. if AUTHOR_FONT == nil then - AUTHOR_FONT = fontLoad("Singe/FreeSansBold.ttf", AUTHOR_FONT_POINTS) + AUTHOR_FONT = fontLoad(AUTHOR_FONT_FILE, AUTHOR_FONT_POINTS) fontSelect(AUTHOR_FONT) fontQuality(FONT_QUALITY_BLENDED) end @@ -5150,9 +5163,9 @@ function authorSwitchUp(what, device) end --- ===== The KarisFramework game loop =========================================================== +-- ===== The quick-time-event game loop =========================================================== -- --- The qte behaviour plays a laserdisc quick-time-event game the way the KarisFramework does: +-- The qte behaviour plays a laserdisc quick-time-event game: -- an attract loop, credits and a start, levels of scenes, each scene a run of moves with a -- window of frames to answer in, a death clip and a life lost for a wrong or missed one, a -- continue screen, a game over, and the framework's dips deciding how strict, how many lives, @@ -5161,14 +5174,18 @@ end -- The state lives in one table, q, under the framework's own names -- iScore, iLives, iLevel, -- currentMove, move[], stage[], SCOREMOVE, offsetGetReady, dip_Difficulty -- because a game -- brings Lua of its own: its settings script declares the tables and a setupMoves that may read --- the state (Space Ace picks a scene by which levels are beaten), and its Script/addons.singe +-- the state (a game may pick a scene by which levels are beaten), and its Script/addons.singe -- hooks the loop (startConf, swapLevel, swapScene, swapDeath, specialScore, doLevelSelect). -- Both run inside a shim whose globals are q, so they play as they do in the framework. The --- description names them under game.qte (util/forgePortKaris.lua writes it), and carries a +-- description names them under game.qte (the port converter writes it), and carries a -- snapshot of the same tables for the editor. The states and their numbers are the -- framework's, so a run of the original and a run of the port trace alike (FORGE.md 14). local Q = {} -- The loop's functions, one table so the file keeps within Lua's count of locals. +local K = {} -- The held style's own functions. +-- The held style's functions: its own where it has one, the shared screens' otherwise. A game's +-- q.qteFns is this or Q, so a shared screen calls the style's scorer, start, and board. +local HELD_FNS = setmetatable({}, { __index = function(_, name) return K[name] or Q[name] end }) local QTE = { -- The framework's constants, as globals.singe declares them. UP = 1, DOWN = 2, LEFT = 3, RIGHT = 4, BUTTON1 = 5, BUTTON2 = 6, BUTTON3 = 7, BUTTON4 = 8, @@ -5176,7 +5193,7 @@ local QTE = { MASH = 17, MASHMIN = 18, MASHMAX = 19, LETGO = 20, HOLDUP = 21, HOLDDOWN = 22, HOLDLEFT = 23, HOLDRIGHT = 24, HOLDBUT = 25, DOUBLE = 26, RUN = 27, RUNMIN = 28, RUNMAX = 29, MASH2 = 30, MASH2MIN = 31, MASH2MAX = 32, MULTI = 33, LOOPLEFT = 35, LOOPRIGHT = 36, CHOOSE = 50, PATH = 51, YESNO = 52, TIMED = 53, MTIMED = 54, WAY = 98, WAYOUT = 99, SKIP = 100, - -- The MazescaterFramework's compound kinds (LINEA): two and three inputs in turn, circles + -- The compound kinds: two and three inputs in turn, circles -- from any of four starts, mashes of one direction or button (MASH3 alternates BUTTON1 and -- BUTTON3), and a direction with BUTTON2 or BUTTON3, or two directions with a button. DLB = 149, DRB = 150, ULB = 151, URB = 152, MASH3 = 153, MASH3MIN = 154, MASH3MAX = 155, @@ -5192,12 +5209,12 @@ local QTE = { ACT3UPLEFT = 236, ACT3UPRIGHT = 237, ACT3DOWNLEFT = 238, ACT3DOWNRIGHT = 239, NOMOVE = -1, MOVEPENDING = -2, MOVEFAIL = -3, OUT = 1000, inputFrmStart = 1, inputFrmEnd = 2, correctMove = 3, moveDeath = 4, moveFrmStart = 7, moveFrmEnd = 8, curDeathStart = 1, curDeathEnd = 2, - TITLE = 1, INTROCLIP = 2, INTROCLIPEND = 3, TOTALSCENES = 4, MIRROR = 5, DTHMIRROR = 6, LVLREPLAY = 7, + TITLE = 1, INTROCLIP = 2, INTROCLIPEND = 3, TOTALSCENES = 4, MIRROR = 5, DTHMIRROR = 6, LVLREPLAY = 7, SELECTSTILL = 8, LEVELSTARTED = 1, BEATSTATUS = 2, DEATHCOUNT = 3, SCENEID = 1, SCENECOMPLETE = 2, NODEATH = -1, NODEATHSCORE = -2, lvlSetup = 0, lvlRunning = 1, lvlEnd = 2, lvlPlayDeath = 3, lvlPlayRest = 4, branch01 = 10, branch02 = 11, branch03 = 12, branch04 = 13, branch05 = 14, branch06 = 15, branch07 = 16, branch08 = 17, branch09 = 18, branch10 = 19, branch11 = 20, branch12 = 21, levelIntro = 0, levelContinue = 100, levelGameOver = 101, levelChoose = 102, levelNormal = 103, levelService = 104, levelMap = 105, levelHighScore = 106, - levelMovie = 107, levelSave = 108, levelDiffScreen = 109, levelFinish = 110, levelTrophy = 111, levelExit = 112, levelSecret = 1000, + levelMovie = 107, levelSave = 108, levelDiffScreen = 109, levelFinish = 110, levelTrophy = 111, levelExit = 112, levelGraphic = 113, levelPerf = 114, levelSecret = 1000, DOPT_FREEPLAY = 0, DOPT_INFINITE_CONTINUES = 4, WHITE = 1, RED = 2, BLUE = 3, YELLOW = 4, GREEN = 5, ORANGE = 6, PINK = 7, PURPLE = 8, LIGHTBLUE = 9, GREY = 10, BLACK = 11, MISC = 12, TOP = 1, BOTTOM = 2, MIDDLE = 3 @@ -5246,7 +5263,8 @@ local function qteFlagOf(switch) QTE.FLAG_OF = { [SWITCH_UP] = "p1UP", [SWITCH_DOWN] = "p1DOWN", [SWITCH_LEFT] = "p1LEFT", [SWITCH_RIGHT] = "p1RIGHT", [SWITCH_BUTTON1] = "p1BUTTON1", [SWITCH_BUTTON2] = "p1BUTTON2", [SWITCH_BUTTON3] = "p1BUTTON3", [SWITCH_BUTTON4] = "p1BUTTON4", [SWITCH_START1] = "p1START1", [SWITCH_START2] = "p1START2", [SWITCH_COIN1] = "p1COIN1", [SWITCH_COIN2] = "p1COIN2", - [SWITCH_SERVICE] = "p1SERVICE" } + [SWITCH_SERVICE] = "p1SERVICE", [SWITCH_TEST] = "p1TEST", [SWITCH_CONSOLE] = "p1CONSOLE", + [SWITCH_SKILL1] = "p1SKILL1", [SWITCH_SKILL2] = "p1SKILL2", [SWITCH_SKILL3] = "p1SKILL3" } end return QTE.FLAG_OF[switch] @@ -5332,6 +5350,17 @@ function Q.setLang(q) end +-- Whether the clip playing has reached its end: at or past it, or exactly at it +-- (clipEndExact, as the first copies of the segmented lineage test). +function Q.clipEnded(q) + if Q.option(q, "clipEndExact", false) then + return q.currentFrame == q.iFrameEnd + end + + return q.currentFrame >= q.iFrameEnd +end + + function Q.setupClip(q, from, to) q.iFrameStart = from q.iFrameEnd = to @@ -5350,30 +5379,136 @@ function Q.sound(q, name) end --- The framework's 3.32b, the Hypseus library's copy, where it parts from 3.31c. -function Q.karis332(q) - return q.snapshot.framework == "3.32b" +-- The settings the games' config reader turns from 1 and 0 into true and false (a 0 is true in +-- Lua, so a switched-off one would show). +local QTE_CONFIG_FLAGS = { "dip_Hints", "ShowTop", "ShowLCD", "ShowLevel", "ShowTitle" } +-- A description's clips by name, and the globals the loops read for each one's first and last +-- frames. The port converter reads this table from here, so the names are written once. +local QTE_CLIPS = { + { "title", "offsetTitle", "offsetTitleend" }, + { "gameIntro", "offsetIntroGame", "offsetIntroGameend" }, + { "getReady", "offsetGetReady", "offsetGetReadyEnd" }, + { "resurrect", "offsetResurrect", "offsetResurrectEnd" }, + { "extraDeath", "offsetSupDeath", "offsetSupDeathEnd" }, + { "continue", "offsetContinue", "offsetContinueend" }, + { "gameOver", "offsetGameOver", "offsetGameOverend" }, + { "gameOverAlt", "offsetGameOverAlt", "offsetGameOverAltend" }, + { "clear", "offsetClear", "offsetClearend" }, + { "newHighScore", "offsetNewHScore", "offsetNewHScoreend" }, + { "enterName", "offsetEnterHScore", "offsetEnterHScoreend" }, + { "rankings", "offsetRankings", "offsetRankingsend" }, + { "quit", "offsetQuit", "offsetQuitend" } +} +-- A description's other named fields, as group.name, and the global the loops read for each. +-- The port converter reads this table from here too. +local QTE_FIELDS = { + { "scoring.move", "SCOREMOVE" }, + { "scoring.perDifficulty", "BUFFMOVE" }, + { "scoring.scene", "SCORESCENE" }, + { "scoring.deathPenalty", "DEATHPENALTY" }, + { "scoring.level", "SCORELEVEL" }, + { "scoring.noDeathBonus", "PERFECTBONUS" }, + { "scoring.game", "SCOREGAME" }, + { "scoring.secret", "SCORESECRET" }, + { "scoring.extraLifeEvery", "EXTRALIFE" }, + { "lifeBar.size", "BarSize" }, + { "lifeBar.missCost", "BarMin" }, + { "lifeBar.regainAfter", "BarBonus" }, + { "windowDelay.normal", "PenalNormal" }, + { "windowDelay.hard", "PenalHard" }, + { "windowDelay.extreme", "PenalExtreme" }, + { "order.play", "PlayOrder" }, + { "order.levels", "LvlOrder" }, + { "order.tiers", "Tiers" }, + { "order.mapStart", "MapStart" }, + { "deaths.clips", "Death" }, + { "deaths.randomCount", "totalDeath" }, + { "secret.allowed", "AllowSecret" }, + { "video.fps", "MovieFPS" }, + { "video.relativeFrames", "RelativeFrames" } +} + + +-- The description's snapshot as the game's own globals: its settings, its clips, stills, and named +-- fields (scoring, lifeBar, windowDelay, order, deaths, secret, video), the level rows the loops +-- read by position (built from its levels, the secret one at levelSecret), and its dips over all +-- of them. +function Q.applySnapshot(q, data) + for key, value in pairs(data.settings or {}) do + q[key] = value + end + -- The clips and stills under their own names, as the globals the loops read: a still is + -- frame and its name, capitalised; the attract steps are numbered from 01. + for _, clip in ipairs(QTE_CLIPS) do + local span = (data.clips or {})[clip[1]] + + if span then + q[clip[2]], q[clip[3]] = span[1], span[2] + end + end + for step, span in ipairs((data.clips or {}).attract or {}) do + q[string.format("offsetIntro%02d", step)] = span[1] + q[string.format("offsetIntro%02dend", step)] = span[2] + end + for name, frame in pairs(data.stills or {}) do + q["frame" .. name:sub(1, 1):upper() .. name:sub(2)] = frame + end + for _, field in ipairs(QTE_FIELDS) do + local group, name = field[1]:match("^(%w+)%.(%w+)$") + local value = (data[group] or {})[name] + + if value ~= nil then + q[field[2]] = value + end + end + if data.levels then + q.Level = {} + for key, level in pairs(data.levels) do + local index = (key == "secret") and q.levelSecret or key + + q.Level[index] = { level.title, level.intro, level.introEnd, level.scenes, level.mirror, level.deathMirror, level.replay, level.selectStill } + end + end + for key, value in pairs(data.dips or {}) do + q[key] = value + end end --- The secret level's combination on the attract screens: the framework's 3.32b asks for --- BUTTON4 and UP, the earlier versions for BUTTON2, BUTTON3, UP, and RIGHT. +-- A game's options (FORGE.md 15.4): where the games this behaviour plays differ, the +-- description says which way under qte.options, and nothing asks where a game came from. The +-- default is what a description without the option gets. +function Q.option(q, name, default) + local options = q.snapshot.options + local value = options and options[name] + + -- Not in the description: the style's own default, then the caller's. + if value == nil then + value = q.optionDefaults and q.optionDefaults[name] + end + if value == nil then + return default + end + + return value +end + + +-- The secret level's combination on the attract screens: every switch in secretInputs held. +local SECRET_INPUTS = { "p1BUTTON2", "p1BUTTON3", "p1UP", "p1RIGHT" } + + function Q.secretPressed(q) if not q.AllowSecret then return false end - if Q.karis332(q) then - return q.p1BUTTON4 and q.p1UP + for _, name in ipairs(Q.option(q, "secretInputs", SECRET_INPUTS)) do + if not q[name] then + return false + end end - return q.p1BUTTON2 and q.p1BUTTON3 and q.p1UP and q.p1RIGHT -end - - --- The MazescaterFramework (LINEA): Karis 3.31c with the compound kinds, a longer look at --- the hints, a slower MASH2, and every switch counted for a mash. -function Q.mazescater(q) - return (tostring(q.snapshot.framework or "")):find("Mazescater", 1, true) ~= nil + return true end @@ -5402,19 +5537,33 @@ function Q.addPoints(q, thisMuch, thisValue) if q.specialScore then q.specialScore(thisValue) end + -- The death-count game type scores nothing. + if q.dip_GameType == Q.option(q, "deathCountGameType", nil) then + return + end if q.dip_GameType ~= 3 then local worth = (q.thisScore == 0) and thisMuch or q.thisScore q.iScore = q.iScore + worth q.iExtraLife = q.iExtraLife + worth - if (q.EXTRALIFE > 0) and (q.iExtraLife >= q.EXTRALIFE) and (q.dip_GameType == 0) then + if (q.EXTRALIFE > 0) and (q.iExtraLife >= q.EXTRALIFE) and (q.dip_GameType == 0) and (((q.i2P or 0) == 0) or q.arcadeEdition) then q.iExtraLife = 0 if q.iLives < q.dip_LivesPerCredit then Q.sound(q, "sndvictory") q.iLives = q.iLives + 1 end end - if (q.dip_GameType == 1) and (q.iRightMv == q.BarBonusT) and (q.BarBonusT ~= 0) and (q.iLifeBar < q.BarSize) then + -- barBonusRestarts: the count to the life bar's bonus starts over at the bonus, a full bar + -- or none, and a tilt with it; otherwise it starts over only when the bar gains. + if Q.option(q, "barBonusRestarts", false) then + if (q.dip_GameType == 1) and (q.iRightMv == q.BarBonusT) then + q.iRightMv, q.iTilt = 0, 0 + q.bShowWarnTilt, q.bShowTilt = false, false + if (q.BarBonusT ~= 0) and (q.iLifeBar < q.BarSize) then + q.iLifeBar = q.iLifeBar + 1 + end + end + elseif (q.dip_GameType == 1) and (q.iRightMv == q.BarBonusT) and (q.BarBonusT ~= 0) and (q.iLifeBar < q.BarSize) then q.iRightMv = 0 q.iLifeBar = q.iLifeBar + 1 end @@ -5467,11 +5616,100 @@ function Q.beatGameWithOneCredit(q) end -function Q.newScore(q, score) - for k = 1, 10 do - local entry = q.highScores[k] +-- The key the boards are kept under in the engine's save store, per game. +local BOARDS_KEY = "forge.boards" +-- The names a board entry takes when the player enters none, drawn at random as the games draw. +local BOARD_NAMES = { "CAL", "AKI", "DON", "TYG", "KIM", "LAR", "KOR", "MOR", "KAR", "CYR" } - if entry and (score >= entry[2]) then + +-- The game's boards: the ones an earlier run of this game saved, or the description's (what it +-- shipped with). Three are ten { name, score } entries, the best first (normal, life bar, +-- survivor; the records shown are their first entries), and percent is each level's best +-- { name, percent, difficulty letter }. +function Q.boardsLoad(q, data) + local text = saveGet(BOARDS_KEY) + local chunk = (type(text) == "string") and load(text, "boards", "t", {}) or nil + local saved = chunk and chunk() or nil + local source = (type(saved) == "table") and saved or data.boards or { normal = data.highScores } + + q.boards = { percent = {} } + for _, name in ipairs({ "normal", "lifeBar", "survivor" }) do + q.boards[name] = {} + for k, entry in ipairs(source[name] or source.normal or {}) do + q.boards[name][k] = { entry[1], tonumber(entry[2]) } + end + end + for k, entry in ipairs(source.percent or {}) do + q.boards.percent[k] = { entry[1], tonumber(entry[2]), entry[3] } + end + q.iTopN = q.boards.normal[1] and q.boards.normal[1][2] or 0 + q.iTopLB = q.boards.lifeBar[1] and q.boards.lifeBar[1][2] or 0 + q.iTopS = q.boards.survivor[1] and q.boards.survivor[1][2] or 0 +end + + +-- The boards kept for the next run of the game. +function Q.boardsSave(q) + saveSet(BOARDS_KEY, "return " .. sourceOf(q.boards)) +end + + +-- The board the game type plays for: the life bar's, the survivor's, or the normal one. +function Q.boardFor(q) + if q.dip_GameType == 1 then + return q.boards.lifeBar + elseif q.dip_GameType == 3 then + return q.boards.survivor + end + + return q.boards.normal +end + + +-- A result onto its game type's board, above the first entry it equals or beats, the rest +-- moved down and the last dropped; a name left empty is one of BOARD_NAMES, drawn at random. +function Q.boardUpdate(q, name, score) + local board = Q.boardFor(q) + + if name == "" then + name = BOARD_NAMES[math.random(#BOARD_NAMES)] + end + for k = 1, 10 do + if board[k] and (score >= board[k][2]) then + table.insert(board, k, { name, score }) + board[11] = nil + return + end + end +end + + +-- The score to beat shown for the game type: the life bar's, the survival's, the death count's +-- record, or the board's. +function Q.topFor(q) + if q.dip_GameType == 1 then + return q.iTopLB + elseif q.dip_GameType == 3 then + return q.iTopS + elseif q.dip_GameType == Q.option(q, "deathCountGameType", nil) then + return q.hsDR[q.dip_Difficulty + 1] + end + + return q.iTopN +end + + +-- Whether a result makes the board: a score among the ten, or, for the death-count game type, +-- no more deaths than the difficulty's record. +function Q.newScore(q, score) + -- The death-count game type ranks the fewest deaths, against the difficulty's record. + if q.dip_GameType == Q.option(q, "deathCountGameType", nil) then + return score <= q.hsDR[q.dip_Difficulty + 1] + end + for k = 1, 10 do + local entry = Q.boardFor(q)[k] + + if entry and (score >= tonumber(entry[2])) then return true end end @@ -5565,7 +5803,16 @@ end function Q.nextLevel(q, thisLevel) - q.iScPlayed, q.iScDeath, q.iTotDeath = 0, 0, 0 + q.iScPlayed = 0 + q.bLvlJump = false + -- A maze game keeps its scene's deaths across its levels, but for the arcade edition. + if q.arcadeEdition or not q.MazeGame then + q.iScDeath = 0 + end + -- The game type whose deaths are counted keeps its total from level to level. + if q.dip_GameType ~= Q.option(q, "deathCountGameType", nil) then + q.iTotDeath = 0 + end if q.dip_PlayStyle == 3 then q.lvlState = QTE.lvlSetup q.currentLevel = QTE.levelMap @@ -5574,7 +5821,7 @@ function Q.nextLevel(q, thisLevel) q.iLevel = q.iPath q.iPath = 0 q.iScene = 0 - else + elseif not q.MazeGame then for k = 1, q.finalstage - 1 do if q.LvlOrder[k] == thisLevel then q.iLevel = q.LvlOrder[k + 1] @@ -5611,7 +5858,9 @@ function Q.reOrder(q, thisLevel) end -function Q.onToNextLevel(q) +-- On from a level: the map, the order drawn again, the level select (play style 4), or the next +-- level in order. +function Q.onToNextLevel(q, reordering) q.bSkipIntroClip = false q.iLiveSave = q.iLives q.iScoreSave = q.iScore @@ -5622,6 +5871,15 @@ function Q.onToNextLevel(q) if q.dip_PlayStyle == 3 then q.lvlState = QTE.lvlSetup q.currentLevel = QTE.levelMap + elseif reordering then + Q.reOrder(q, q.iLevel) + q.lvlState = QTE.lvlSetup + q.currentLevel = QTE.levelNormal + elseif Q.option(q, "levelSelect", false) and (q.dip_PlayStyle == 4) then + -- One level at a time: back to the level select for the next. + q.dip_StartLevel = q.dip_StartLevel + 1 + q.iScene = 0 + Q.toLevelSelect(q) else Q.nextLevel(q, q.iLevel) q.lvlState = QTE.lvlSetup @@ -5630,42 +5888,26 @@ function Q.onToNextLevel(q) end +-- A level done, by its replay setting: 0 goes on at once, 1 plays it once more first, and any +-- other but -1 draws the level order again the first time through. function Q.levelReplay(q) local replay = q.Level[q.iLevel][QTE.LVLREPLAY] if replay == 0 then - Q.onToNextLevel(q) + Q.onToNextLevel(q, false) elseif replay == 1 then if not q.stage[q.iLevel][QTE.LEVELSTARTED] then q.stage[q.iLevel][QTE.LEVELSTARTED] = true else - Q.onToNextLevel(q) + Q.onToNextLevel(q, false) end - elseif replay > 1 then - q.bSkipIntroClip = false - q.iLiveSave = q.iLives - q.iScoreSave = q.iScore - q.bAllowSave = true - q.bRes = true - q.iScoreTemp = 0 - q.iBonus = 0 + elseif (replay > 1) or (replay < -1) then if not q.stage[q.iLevel][QTE.LEVELSTARTED] then q.stage[q.iLevel][QTE.LEVELSTARTED] = true - if q.dip_PlayStyle == 3 then - q.currentLevel = QTE.levelMap - else - Q.reOrder(q, q.iLevel) - q.currentLevel = QTE.levelNormal - end + Q.onToNextLevel(q, true) else - if q.dip_PlayStyle == 3 then - q.currentLevel = QTE.levelMap - else - Q.nextLevel(q, q.iLevel) - q.currentLevel = QTE.levelNormal - end + Q.onToNextLevel(q, false) end - q.lvlState = QTE.lvlSetup end end @@ -5833,38 +6075,80 @@ function Q.setupLevel(q, thisLevel) q.bFlip = true end end + q.multi = {} Q.loadMoves(q, thisLevel, q.iScene) - Q.setupFrames(q, thisLevel) - if q.bAllowSave and (q.dip_GameType ~= 2) and (q.dip_GameType ~= 3) then + q.qteFns.setupFrames(q, thisLevel) + if q.bAllowSave and (q.dip_GameType ~= 2) and (q.dip_GameType ~= 3) and ((q.i2P or 0) == 0) then + -- saveIcon: the style shows its disk where the game saves; keepsSaves writes the + -- autosave there too. + if Q.option(q, "saveIcon", false) then + if Q.option(q, "keepsSaves", true) then + Q.autoSave(q, 4) + end + q.bShowDiskA = true + q.altState = QTE.branch01 + end q.bAllowSave = false end end +-- The windows style's reset of a move's judging state. +function Q.resetJudge(q) + q.bTestMash, q.bTestMashL, q.bTestMashR, q.bTestRunL, q.bTestRunR, q.bTestHold = false, false, false, false, false, false + q.iMash, q.iMulti, q.iLoopStep, q.iLenHold = 0, 0, 0, 0 + q.bCalc = true +end + + +-- The windows style's reset after a move answered: its judge has cleared what the move used. +function Q.resetMoveDone(q) + q.iMulti, q.iLoopStep = 0, 0 +end + + +-- The windows style's reset of a move's judging state, and the input with it. +function Q.resetVar(q) + Q.clearInput(q) + Q.resetJudge(q) +end + + -- A wrong or missed move: the death clip and a life gone -- or nothing at all, for a move whose -- death is negative. function Q.setupDeathClip(q) - local level = q.Level[q.iLevel] - local curDeath = q.move[q.currentMove][QTE.moveDeath] + local level = q.Level[q.iLevel] + local m = q.move[q.currentMove] + -- A game's swapDeath may choose another death than the move's. + q.curDeath = m[QTE.moveDeath] if q.swapDeath then q.swapDeath() end - q.bShowLvl = false - Q.clearInput(q) - q.bTestMash, q.bTestMashL, q.bTestMashR, q.bTestRunL, q.bTestRunR, q.bTestHold = false, false, false, false, false, false - q.iMash, q.iMulti, q.iLoopStep, q.iLenHold = 0, 0, 0, 0 - q.bCalc = true - if curDeath < 0 then + q.bShowLvl, q.bShowNext, q.bShowWarnTilt = false, false, false + q.qteFns.resetVar(q) + if q.curDeath < 0 then q.lvlState = QTE.lvlPlayRest return end q.lvlState = QTE.lvlPlayDeath - q.iLives = q.iLives - 1 - if curDeath == 0 then - curDeath = math.random(q.totalDeath) + -- deathCountGameType: the game type that counts deaths instead of taking lives. + if q.dip_GameType ~= Q.option(q, "deathCountGameType", nil) then + q.iLives = q.iLives - 1 end - local clip = q.Death[curDeath] + if q.i2P == 1 then + if q.iLives == 0 then + q.b1PEnd, q.i1PScore = true, q.iScore + end + elseif q.i2P == 2 then + if q.iLives == 0 then + q.b2PEnd, q.i2PScore = true, q.iScore + end + end + if m[QTE.moveDeath] == 0 then + q.curDeath = math.random(q.totalDeath) + end + local clip = q.Death[q.curDeath] if q.bFlip then Q.setupClip(q, clip[1] + level[QTE.DTHMIRROR], clip[2] + level[QTE.DTHMIRROR]) @@ -5875,16 +6159,14 @@ function Q.setupDeathClip(q) Q.sound(q, "sndwrong") end if q.dip_Rewind == 1 then - local m = q.move[q.currentMove] - if q.currentMove == 1 then q.iPauseFrame = m[QTE.inputFrmStart] - 15 q.currentMove = 0 - elseif m[QTE.correctMove] == QTE.CHOOSE then + elseif m[QTE.correctMove] == q.CHOOSE then m[QTE.moveDeath] = q.numChoice q.iPauseFrame = q.move[q.currentMove - 2][QTE.inputFrmEnd] + 1 q.currentMove = q.currentMove - 2 - elseif m[QTE.correctMove] == QTE.LETGO then + elseif m[QTE.correctMove] == q.LETGO then q.iPauseFrame = q.move[q.currentMove - 1][QTE.inputFrmStart] - 15 q.currentMove = q.currentMove - 2 else @@ -5937,13 +6219,13 @@ end function Q.checkLet(q, playerMove, curMove) local m = q.move[q.currentMove] - if (q.currentFrame == m[QTE.inputFrmStart]) and (playerMove == QTE.NOMOVE) then - return QTE.MOVEFAIL - elseif (q.currentFrame == m[QTE.inputFrmEnd] - 1) and (playerMove == QTE.NOMOVE) then + if (q.currentFrame == m[q.inputFrmStart]) and (playerMove == q.NOMOVE) then + return q.MOVEFAIL + elseif (q.currentFrame == m[q.inputFrmEnd] - 1) and (playerMove == q.NOMOVE) then return curMove end - return QTE.MOVEPENDING + return q.MOVEPENDING end @@ -5970,21 +6252,24 @@ end function Q.checkMash(q, playerMove, curMove) - local z = QTE.MOVEPENDING + local z = q.MOVEPENDING - if playerMove == QTE.BUTTON1 then + if playerMove == q.BUTTON1 then q.bTestMash = false if q.iMash >= q.mashCounter then z = curMove - q.p1BUTTON1 = false + -- mashClearsButton: the press that finished the mash is used up. + if Q.option(q, "mashClearsButton", true) then + q.p1BUTTON1 = false + end end else if q.iMash > 0 then q.iMash = q.iMash - q.unMash end - if playerMove ~= QTE.NOMOVE then + if playerMove ~= q.NOMOVE then q.iMash = 0 - z = QTE.MOVEFAIL + z = q.MOVEFAIL end end @@ -6136,21 +6421,25 @@ end -- ----- The screens --------------------------------------------------------------------------- function Q.startGame(q) - math.randomseed(KARIS_SEED or os.time()) + local levelSelect = Q.option(q, "levelSelect", false) + local deathCount = Q.option(q, "deathCountGameType", nil) + + math.randomseed(AUTHOR_SEED or os.time()) math.random(100) + q.bLvlJump = false if q.bExtendedPlay then Q.initStages(q) q.currentLevel = QTE.levelNormal q.iLevel = QTE.levelSecret else - if q.iCredits > 0 then + if (q.iCredits > 0) and ((q.i2P or 0) == 0) then q.iCredits = q.iCredits - 1 end q.iScore, q.iScoreTemp, q.iBonus, q.iScPlayed, q.iScDeath, q.iTotDeath = 0, 0, 0, 0, 0, 0 + q.iRightMv, q.iWrongMv, q.iTiltMv, q.numTrophy = 0, 0, 0, 0 if q.currentLevel == QTE.levelContinue then q.currentLevel = q.iTempLevel q.iLifeBar = q.BarSize - q.iRightMv, q.iWrongMv = 0, 0 if q.dip_Rewind == 0 then q.bSwap = true Q.levelReplay(q) @@ -6164,13 +6453,11 @@ function Q.startGame(q) q.iScene = 0 Q.levelReplay(q) elseif q.dip_Rewind == 3 then - local after = q.move[q.currentMove + 1] - if q.currentMove == q.totalMoves then q.bSwap = true q.scene[q.iLevel][q.iScene][QTE.SCENECOMPLETE] = true q.iScene, q.currentMove, q.bSave = q.iScene + 1, 1, true - elseif after and (q.currentMove + 1 == q.totalMoves) and ((after[3] == QTE.CHOOSE) or (after[3] == QTE.LETGO) or (after[3] == QTE.PATH) or (after[3] == QTE.YESNO)) then + elseif Q.nextEndsScene(q) then q.bSwap = true q.scene[q.iLevel][q.iScene][QTE.SCENECOMPLETE] = true q.iScene, q.currentMove = q.iScene + 1, 1 @@ -6185,8 +6472,14 @@ function Q.startGame(q) q.iContinues, q.iScene, q.currentMove = 0, 0, 1 if (q.dip_GameType == 0) or (q.dip_GameType == 1) or (q.dip_GameType == 4) then q.iLifeBar = q.BarSize - q.iRightMv, q.iWrongMv = 0, 0 - q.iTop = (q.dip_GameType == 1) and q.iTopLB or q.iTopN + -- The death-count game type plays in order, from the start unless a level was unlocked. + if q.dip_GameType == deathCount then + q.dip_PlayStyle = 0 + if not q.bUnlockSel then + q.dip_StartLevel, q.dip_StartScene = 1, 1 + end + end + q.iTop = Q.topFor(q) if q.dip_PlayStyle == 0 then Q.doMixSEQ(q) q.iLevel = q.dip_StartLevel @@ -6208,26 +6501,39 @@ function Q.startGame(q) else q.currentLevel = QTE.levelMap end + elseif levelSelect and (q.dip_PlayStyle == 4) then + q.iLevel, q.iScene = q.dip_StartLevel, 0 + Q.toLevelSelect(q) end elseif q.dip_GameType == 2 then q.bShowLives, q.bShowScore = false, false - q.iRightMv, q.iWrongMv = 0, 0 - q.iLevel, q.iScene = q.dip_StartLevel, 0 - q.currentLevel = QTE.levelNormal + q.iLevel, q.iScene = q.dip_StartLevel, 0 + -- A style with a level select starts there, unless the game has turned its screen off. + if levelSelect and not q.bNoScreen then + Q.toLevelSelect(q) + else + q.currentLevel = QTE.levelNormal + end elseif q.dip_GameType == 3 then Q.doMixSEQ(q) q.iLevel, q.iScene = 1, 0 q.iTop = q.iTopS q.currentLevel = QTE.levelNormal + elseif levelSelect and (q.dip_GameType == 5) then + q.iLevel, q.iScene = q.dip_StartLevel, 0 + Q.toLevelSelect(q) end if q.startConf then q.startConf() end end end - if q.IngameDiffchoice and (q.dip_Diffshow == 4) and q.bOneDiff then + if q.IngameDiffchoice and (q.dip_Diffshow == 4) and q.bOneDiff and not q.bNoScreen then q.altState = QTE.lvlSetup q.currentLevel = QTE.levelDiffScreen + if (q.i2P or 0) > 0 then + q.bOneDiff = false + end end if (q.offsetIntroGame ~= 0) and (q.iContinues == 0) and (q.dip_StartScene == 1) then Q.setupClip(q, q.offsetIntroGame, q.offsetIntroGameend) @@ -6235,27 +6541,9 @@ function Q.startGame(q) else q.lvlState = QTE.lvlSetup end - q.bShowAction, q.bShowScore, q.bRes = false, false, true - if q.dip_GameType == 1 then - q.iLives = 1 - if q.dip_Difficulty == 0 then - q.BarMinT = q.BarMin - if q.BarBonus > 0 then - q.BarBonusT = q.BarBonus - 1 - end - elseif q.dip_Difficulty == 1 then - q.BarMinT, q.BarBonusT = q.BarMin, q.BarBonus - elseif q.dip_Difficulty == 2 then - q.BarMinT, q.BarBonusT = q.BarMin + 1, q.BarBonus - else - q.BarMinT, q.BarBonusT = q.BarMin + 1, q.BarBonus + 1 - end - elseif q.dip_GameType == 3 then - q.iLives = 1 - else - q.iLives = q.dip_LivesPerCredit - end - q.bShowAction, q.bShowCredits, q.bShowLCD, q.bResetContinue, q.bExtendedPlay = false, false, false, false, false + q.bShowScore, q.bRes = false, true + Q.livesForType(q) + q.bShowAction, q.bShowNext, q.bShowCredits, q.bShowLCD, q.bResetContinue, q.bExtendedPlay = false, false, false, false, false, false end @@ -6380,7 +6668,7 @@ end function Q.doChoose(q) local m = q.move[q.currentMove] - local addPoints = q.addPoints or Q.addPoints -- Kimmy scores its own way. + local addPoints = Q.addPoints q.numChoice = m[QTE.moveDeath] if q.altState == QTE.lvlSetup then @@ -6474,17 +6762,17 @@ function Q.sceneDone(q, thisLevel) if q.dip_GameType ~= 2 then if Q.beatGame(q) then if bonus > 0 then - Q.addPoints(q, bonus, 0) + q.qteFns.addPoints(q, bonus, 0) end - Q.addPoints(q, q.SCOREGAME, 0) + q.qteFns.addPoints(q, q.SCOREGAME, 0) if Q.beatGameWithOneLife(q) then - Q.addPoints(q, q.SCORESECRET, 0) + q.qteFns.addPoints(q, q.SCORESECRET, 0) end else if bonus > 0 then - Q.addPoints(q, bonus, 0) + q.qteFns.addPoints(q, bonus, 0) end - Q.addPoints(q, q.SCORELEVEL, 0) + q.qteFns.addPoints(q, q.SCORELEVEL, 0) end if thisLevel == QTE.levelSecret then discPause() @@ -6503,7 +6791,7 @@ function Q.sceneDone(q, thisLevel) return true end if bonus > 0 then - Q.addPoints(q, bonus, 0) + q.qteFns.addPoints(q, bonus, 0) q.iBonus = q.iBonus + bonus end q.iTotDeath = q.iTotDeath + q.iScDeath @@ -6514,11 +6802,52 @@ function Q.sceneDone(q, thisLevel) end --- After a death clip: the death counted, the rewind setting says where play goes. -function Q.afterDeath(q, thisLevel) +-- Back into play after a death, from the frame the move paused on, and past the move missed. +function Q.resumePlay(q) local m = q.move[q.currentMove] local after = q.move[q.currentMove + 1] + -- managedHud: the arrows and HUD are put back as play resumes. + if Q.option(q, "managedHud", false) then + K.resetArrows(q) + if q.dip_Display == 0 then + q.bShowScore, q.bShowLives, q.bShowLvl = true, true, true + q.bShowTop = q.ShowTop and true or false + else + q.bShowScore, q.bShowLives, q.bShowLvl, q.bShowTop = false, false, false, false + end + end + discSkipToFrame(q.iPauseFrame) + if (q.dip_Rewind == 3) and after and (after[3] == q.LETGO) then + q.bSwap = true + q.currentMove = q.currentMove + 2 + elseif (q.dip_Rewind == 3) and m and (m[3] == q.PATH) then + q.iPathAend = q.path[q.currentMove][4] - 1 + q.iPathAjmp = q.path[q.currentMove][9] + q.currentMove = q.currentMove + 1 + else + q.currentMove = q.currentMove + 1 + end + q.lvlState = QTE.lvlRunning +end + + +-- Whether the next move ends the scene with a kind that rewind mode 3 counts the scene done on. +function Q.nextEndsScene(q) + local after = q.move[q.currentMove + 1] + + if q.currentMove == q.totalMoves then + return true + end + + return after and (q.currentMove + 1 == q.totalMoves) and ((after[3] == q.CHOOSE) or (after[3] == q.LETGO) or (after[3] == q.PATH) or (after[3] == q.YESNO)) +end + + +-- After a death clip (or the extra clip after it): the death counted, the rewind mode says +-- where play goes. getReady: whether the "get ready" clip may play first; "both" shows the +-- whole HUD over it. +function Q.afterDeath(q, thisLevel, getReady) q.stage[thisLevel][QTE.DEATHCOUNT] = q.stage[thisLevel][QTE.DEATHCOUNT] + 1 if q.iLives <= 0 then q.lvlState = QTE.lvlEnd @@ -6534,47 +6863,24 @@ function Q.afterDeath(q, thisLevel) q.scene[thisLevel][i] = { i, false } end q.lvlState = QTE.lvlSetup - elseif (q.dip_Rewind == 3) and ((q.currentMove == q.totalMoves) or (after and (q.currentMove + 1 == q.totalMoves) and ((after[3] == QTE.CHOOSE) or (after[3] == QTE.LETGO) or (after[3] == QTE.PATH) or (after[3] == QTE.YESNO)))) then + elseif (q.dip_Rewind == 3) and Q.nextEndsScene(q) then q.bSwap = true q.scene[q.iLevel][q.iScene][QTE.SCENECOMPLETE] = true q.lvlState = QTE.lvlEnd - elseif q.ShowResurrect and (q.dip_GameType ~= 3) and (q.i2P == 0) then + elseif getReady and q.ShowResurrect and (q.dip_GameType ~= 3) and (q.i2P == 0) then Q.setupClip(q, q.offsetGetReady, q.offsetGetReadyEnd) q.bShowGet, q.bShowTop = true, false + -- managedHud: the style's HUD over the get-ready, all of it on "both". + if Q.option(q, "managedHud", false) then + if (q.dip_Display == 1) or ((q.dip_Display == 2) and getReady == "both") then + q.bShowScore, q.bShowLives, q.bShowLvl = true, true, true + else + q.bShowScore, q.bShowLives, q.bShowLvl, q.bShowTop = false, false, false, false + end + end q.lvlState = QTE.branch09 else - discSkipToFrame(q.iPauseFrame) - if (q.dip_Rewind == 3) and after and (after[3] == QTE.LETGO) then - q.bSwap = true - q.currentMove = q.currentMove + 2 - elseif (q.dip_Rewind == 3) and (m[3] == QTE.PATH) then - q.iPathAend = q.path[q.currentMove][4] - 1 - q.iPathAjmp = q.path[q.currentMove][9] - q.currentMove = q.currentMove + 1 - else - q.currentMove = q.currentMove + 1 - end - q.lvlState = QTE.lvlRunning - end -end - - -function Q.resumeAfterGetReady(q) - local m = q.move[q.currentMove] - local after = q.move[q.currentMove + 1] - - q.lvlState = QTE.lvlRunning - q.bShowGet = false - discSkipToFrame(q.iPauseFrame) - if (q.dip_Rewind == 3) and after and (after[3] == QTE.LETGO) then - q.bSwap = true - q.currentMove = q.currentMove + 2 - elseif (q.dip_Rewind == 3) and m and (m[3] == QTE.PATH) then - q.iPathAend = q.path[q.currentMove][4] - 1 - q.iPathAjmp = q.path[q.currentMove][9] - q.currentMove = q.currentMove + 1 - else - q.currentMove = q.currentMove + 1 + Q.resumePlay(q) end end @@ -6674,7 +6980,7 @@ function Q.judge(q) if q.bCalc then q.bCalc = false q.unMash = 0.07 + q.dip_Difficulty / 100 - q.mashCounter = ((m[QTE.inputFrmEnd] - m[QTE.inputFrmStart]) / fps) * (Q.mazescater(q) and 6 or 9) + q.mashCounter = ((m[QTE.inputFrmEnd] - m[QTE.inputFrmStart]) / fps) * Q.option(q, "alternateMashRate", 9) if kind == QTE.MASH2MIN then q.unMash = q.unMash - 0.01 elseif kind == QTE.MASH2MAX then q.unMash = q.unMash + 0.01 end end thisMove = Q.checkMash2(q, thisMove, kind) @@ -6881,18 +7187,110 @@ function Q.judge(q) end +-- The windows style's verdict on a wrong or missed move: hints first if the dip says so, else the +-- life bar's cost, the pass of the game type that has none, or the death. +function Q.failed(q) + if q.dip_Hints and (q.dip_GameType ~= 1) and (q.dip_GameType ~= 2) then + Q.sound(q, "sndwrong") + q.bShowLvl, q.bShowScore, q.bShowLives = false, false, false + discSkipToFrame(q.frameHints) + Q.timerON(q, Q.option(q, "hintSeconds", 2)) + discPause() + q.lvlState = QTE.branch03 + else + q.iWrongMv = q.iWrongMv + 1 + q.iScDeath = q.iScDeath + 1 + Q.resetJudge(q) + if q.dip_GameType == 2 then + Q.sound(q, "sndwrong") + q.lvlState = QTE.lvlPlayRest + elseif q.dip_GameType == 1 then + q.iLifeBar = q.iLifeBar - q.BarMinT + q.iRightMv = 0 + Q.sound(q, "sndwrong") + q.lvlState = QTE.lvlPlayRest + if q.iLifeBar <= 0 then + Q.setupDeathClip(q) + end + else + Q.setupDeathClip(q) + end + end +end + + +-- The score and lives shown in play: managedHud sets the level and the record with them by the +-- display setting; otherwise both show when the display is on, but for game type 2. +function Q.showPlayFlags(q) + if not Q.option(q, "managedHud", false) then + q.bShowScore = (q.dip_GameType ~= 2) and (q.dip_Display == 0) + q.bShowLives = q.bShowScore + elseif q.dip_GameType == 2 then + q.bShowScore, q.bShowLives = false, false + elseif q.dip_Display == 0 then + q.bShowScore, q.bShowLives, q.bShowLvl = true, true, true + q.bShowTop = q.ShowTop and true or false + else + q.bShowScore, q.bShowLives, q.bShowLvl, q.bShowTop = false, false, false, false + end +end + + +-- A tilt: input before a move's window, counted against the tilt dip; at the limit it costs +-- as a wrong move does, by game type. +function Q.checkTilt(q, m, kind) + if (q.thisMove == q.NOMOVE) or (kind >= 25) or ((q.dip_Tilt or 0) <= 0) or (q.dip_GameType >= 5) then + return + end + q.iTilt = q.iTilt + 1 + q.iTrigTilt = 50 - 10 * q.dip_Tilt + if q.iTilt >= q.iTrigTilt then + q.iPauseFrame = m[QTE.inputFrmEnd] + if (q.dip_GameType == 0) or (q.dip_GameType == 3) or (q.dip_GameType == 4) then + q.lvlState = QTE.branch02 + elseif q.dip_GameType == 1 then + q.iLifeBar = q.iLifeBar - q.BarMinT + q.iRightMv, q.iTilt = 0, 0 + Q.sound(q, "sndwrong") + q.altState = QTE.branch01 + if q.iLifeBar <= 0 then + Q.setupDeathClip(q) + end + elseif q.dip_GameType == 2 then + q.iTiltMv = q.iTiltMv + 1 + q.iTilt = 0 + Q.sound(q, "sndwrong") + q.altState = QTE.branch01 + end + q.bShowTilt = true + elseif (q.iTilt > q.iTrigTilt / 2) and (q.iTilt < q.iTrigTilt) and (q.dip_GameType ~= 5) and (q.dip_GameType ~= 6) then + q.bShowWarnTilt = true + end +end + + +-- The level loop: a scene's setup and its intro, the moves in turn, the deaths and the get-ready, +-- and the end of the scene. The style's own judge, verdict, and resets come through q.qteFns. function Q.doLevel(q) - local thisLevel = q.iLevel - local level = q.Level[thisLevel] + local thisLevel = q.iLevel + local level = q.Level[thisLevel] + local managedHud = Q.option(q, "managedHud", false) if q.lvlState == QTE.lvlSetup then q.bShuffleOrder, q.bPlayPrompt, q.bPlayRight = true, true, true - q.bShowLvl, q.bShowAction = false, false - q.bTestMash, q.bTestMashL, q.bTestMashR, q.bTestRunL, q.bTestRunR, q.bTestHold = false, false, false, false, false, false - q.iMash, q.iMulti, q.iLoopStep, q.iLenHold = 0, 0, 0, 0 + q.bShowLvl, q.bShowAction, q.bShowNext = false, false, false + q.bLvlJump, q.bScnJump = false, false q.bPath, q.bTime, q.bCalc = true, true, true - q.bShowScore = (q.dip_GameType ~= 2) and (q.dip_Display == 0) - q.bShowLives = q.bShowScore + if (q.dip_GameType ~= 1) or (q.iScene == 0) then + q.iTilt = 0 + q.bShowWarnTilt, q.bShowTilt = false, false + end + -- The arcade edition starts every level with the rewind off. + if q.arcadeEdition and (q.iScene == 0) then + q.dip_Rewind = 0 + end + q.qteFns.resetJudge(q) + Q.showPlayFlags(q) if not q.bSave then q.currentMove = 1 end @@ -6903,6 +7301,13 @@ function Q.doLevel(q) if q.ShowResurrect and q.bRes and (q.dip_GameType ~= 3) and (q.i2P == 0) then q.bShowTop = false Q.setupClip(q, q.offsetGetReady, q.offsetGetReadyEnd) + if managedHud then + if (q.dip_Display == 1) or (q.dip_Display == 2) then + q.bShowScore, q.bShowLives, q.bShowLvl = true, true, true + else + q.bShowScore, q.bShowLives, q.bShowLvl, q.bShowTop = false, false, false, false + end + end q.bShowGet = true q.lvlState = QTE.branch08 q.bRes = false @@ -6920,67 +7325,49 @@ function Q.doLevel(q) q.lvlState = QTE.lvlRunning end elseif q.lvlState == QTE.branch02 then - if q.dip_Hints and (q.dip_GameType ~= 1) and (q.dip_GameType ~= 2) then - Q.sound(q, "sndwrong") - q.bShowLvl, q.bShowScore, q.bShowLives = false, false, false - discSkipToFrame(q.frameHints) - Q.timerON(q, Q.mazescater(q) and 4 or 2) - discPause() - q.lvlState = QTE.branch03 - else - q.iWrongMv = q.iWrongMv + 1 - q.iScDeath = q.iScDeath + 1 - q.bTestMash, q.bTestMashL, q.bTestMashR, q.bTestRunL, q.bTestRunR, q.bTestHold = false, false, false, false, false, false - q.iMash, q.iMulti, q.iLoopStep, q.iLenHold = 0, 0, 0, 0 - q.bCalc = true - if q.dip_GameType == 2 then - Q.sound(q, "sndwrong") - q.lvlState = QTE.lvlPlayRest - elseif q.dip_GameType == 1 then - q.iLifeBar = q.iLifeBar - q.BarMinT - q.iRightMv = 0 - Q.sound(q, "sndwrong") - q.lvlState = QTE.lvlPlayRest - if q.iLifeBar <= 0 then - Q.setupDeathClip(q) - end - else - Q.setupDeathClip(q) - end - end + q.qteFns.failed(q) elseif q.lvlState == QTE.branch03 then if Q.timerDue(q) then + -- hintedDeathCounts: a death after the hints counts as the wrong move it was. + if Q.option(q, "hintedDeathCounts", false) then + q.iPath = 0 + q.iWrongMv = q.iWrongMv + 1 + q.iScDeath = q.iScDeath + 1 + end Q.setupDeathClip(q) end elseif q.lvlState == QTE.branch04 then local worth = q.SCOREMOVE + q.dip_Difficulty * q.BUFFMOVE - q.bShowAction, q.bTime = false, true - q.iMulti, q.iLoopStep = 0, 0 + q.bShowAction, q.bShowNext, q.bTime = false, false, true + q.qteFns.resetMoveDone(q) q.lvlState = QTE.lvlPlayRest - if q.bPlayRight then - Q.sound(q, "sndright") - q.bPlayRight = false + -- A move of nothing that was answered scores nothing, but for a plain one outside the + -- arcade edition. + if (q.thisMove ~= q.NOTHING) or ((not q.arcadeEdition) and (q.move[q.currentMove][5] == nil)) then + if q.bPlayRight then + Q.sound(q, "sndright") + q.bPlayRight = false + end + q.iRightMv = q.iRightMv + 1 + Q.addPoints(q, worth, q.currentMove) + q.iScoreTemp = q.iScoreTemp + worth end - q.iRightMv = q.iRightMv + 1 - Q.addPoints(q, worth, q.currentMove) - q.iScoreTemp = q.iScoreTemp + worth elseif q.lvlState == QTE.branch05 then if Q.timerDue(q) then + local deathCount = Q.option(q, "deathCountGameType", nil) + q.bGOAlt = true - if Q.beatGameWithOneLife(q) and (thisLevel ~= QTE.levelSecret) and q.AllowSecret then + if (Q.beatGameWithOneLife(q) or Q.beatGameWithOneCredit(q)) and (thisLevel ~= QTE.levelSecret) and q.AllowSecret and (q.dip_GameType ~= deathCount) then Q.sound(q, "sndvictory") discSkipToFrame(q.frameSecret) discPause() Q.timerON(q, 4) q.lvlState = QTE.branch06 - elseif Q.beatGameWithOneCredit(q) and (thisLevel ~= QTE.levelSecret) and q.AllowSecret then - Q.sound(q, "sndvictory") - discSkipToFrame(q.frameSecret) - discPause() - Q.timerON(q, 4) - q.lvlState = QTE.branch06 - elseif Q.newScore(q, q.iScore) then + elseif (q.dip_GameType ~= deathCount) and Q.newScore(q, q.iScore) then + q.lvlState = QTE.lvlSetup + q.currentLevel = QTE.levelHighScore + elseif (q.dip_GameType == deathCount) and (not q.bUnlockSel) and Q.newScore(q, q.iTotDeath + q.iScDeath) then q.lvlState = QTE.lvlSetup q.currentLevel = QTE.levelHighScore else @@ -6995,19 +7382,23 @@ function Q.doLevel(q) Q.startGame(q) end elseif q.lvlState == QTE.branch07 then - if q.currentFrame >= q.iFrameEnd then - Q.afterDeath(q, thisLevel) + -- extraDeathClipSkips: BUTTON1 cuts the extra clip after a death short. + if (q.currentFrame >= q.iFrameEnd) or (q.p1BUTTON1 and Q.option(q, "extraDeathClipSkips", false)) then + Q.afterDeath(q, thisLevel, "both") end elseif q.lvlState == QTE.branch08 then if q.currentFrame >= q.iFrameEnd then - q.bShowGet = false - q.bShowScore = (q.dip_GameType ~= 2) and (q.dip_Display == 0) - q.bShowLives = q.bShowScore + q.bShowGet = false + Q.showPlayFlags(q) Q.enterScene(q, thisLevel) end elseif q.lvlState == QTE.branch09 then if q.currentFrame >= q.iFrameEnd then - Q.resumeAfterGetReady(q) + q.bShowGet = false + Q.resumePlay(q) + if managedHud then + Q.showPlayFlags(q) + end end elseif q.lvlState == QTE.branch10 then Q.doChoose(q) @@ -7019,15 +7410,31 @@ function Q.doLevel(q) if not Q.sceneDone(q, thisLevel) then q.lvlState = QTE.lvlEnd end + elseif q.lvlState == q.branch13 then + -- managedHud's pause on the hints frame after a death, with the score and lives up. + if Q.timerDue(q) then + Q.afterDeath(q, thisLevel, false) + else + q.bShowScore, q.bShowLives = true, true + end elseif q.lvlState == QTE.lvlPlayRest then + local m = q.move[q.currentMove] + q.bPlayRight = true - q.thisMove = Q.scanInput(q) - if q.currentFrame >= q.move[q.currentMove][QTE.moveFrmEnd] then - if q.iPathAjmp == QTE.OUT then + if q.currentMove < q.totalMoves then + q.bCheckMove = true + end + q.thisMove = Q.scanInput(q) + if q.currentFrame >= m[QTE.moveFrmEnd] then + q.bCheckMove = false + if q.iPathAjmp == q.OUT then q.currentMove = q.iPathAjmp q.iPathAend, q.iPathAjmp = 0, 0 q.lvlState = QTE.branch12 - return + -- outEndsSceneNow: the scene's end is taken in this same step, not the next. + if not Q.option(q, "outEndsSceneNow", false) then + return + end elseif (q.iPathAend ~= 0) and (q.currentMove == q.iPathAend) then q.currentMove = q.iPathAjmp q.iPathAend, q.iPathAjmp = 0, 0 @@ -7039,69 +7446,117 @@ function Q.doLevel(q) q.currentMove = q.currentMove + 1 end if q.currentMove <= q.totalMoves then - local m = q.move[q.currentMove] + local next = q.move[q.currentMove] - if ((q.currentFrame + 2) <= m[QTE.moveFrmStart]) or (q.currentFrame > m[QTE.moveFrmStart]) then - discSkipToFrame(m[QTE.moveFrmStart]) + if (q.currentFrame + 2 <= next[QTE.moveFrmStart]) or (q.currentFrame > next[QTE.moveFrmStart]) then + discSkipToFrame(next[QTE.moveFrmStart]) end q.bShowAction = false q.bPlayPrompt = true - if m[QTE.correctMove] == QTE.CHOOSE then + if next[QTE.correctMove] == q.CHOOSE then q.altState = QTE.lvlSetup q.lvlState = QTE.branch10 else q.lvlState = QTE.lvlRunning end - elseif not Q.sceneDone(q, thisLevel) then - if q.currentFrame >= q.sceneEnd then - q.lvlState = QTE.lvlEnd + else + q.bShowNext = false + if not Q.sceneDone(q, thisLevel) then + if q.currentFrame >= q.sceneEnd then + q.lvlState = QTE.lvlEnd + end end end end elseif q.lvlState == QTE.lvlRunning then - local m = q.move[q.currentMove] + local m = q.move[q.currentMove] + local kind = m and m[QTE.correctMove] if m == nil then q.lvlState = QTE.lvlEnd - elseif (q.currentFrame >= m[QTE.inputFrmStart]) and (q.currentFrame <= m[QTE.inputFrmEnd]) then + return + end + if (q.dip_GameType <= 2) and q.dip_Next then + q.bShowNext = true + end + -- waitForWindowStart: a window that ends before it starts waits for its start; otherwise + -- its end is what closes it. + local early = q.currentFrame < m[QTE.inputFrmStart] + local late = (q.currentFrame > m[QTE.inputFrmEnd]) and not (early and Q.option(q, "waitForWindowStart", false)) + + if (not early) and (not late) then q.bShowAction = true - if q.bPlayPrompt and ((q.dip_ShowAction == 1) or (q.dip_ShowAction == 5)) and (m[QTE.correctMove] < 50) then + if q.bPlayPrompt and ((q.dip_ShowAction == 1) or (q.dip_ShowAction == 5)) and (kind < 50) and not q.arcadeEdition then Q.sound(q, "sndcoin") q.bPlayPrompt = false end - Q.judge(q) - elseif (q.currentFrame > m[QTE.inputFrmEnd]) and (q.thisMove == QTE.NOMOVE) and (m[QTE.moveDeath] == -1) then - q.bShowAction = false - q.lvlState = QTE.lvlPlayRest - elseif (q.currentFrame > m[QTE.inputFrmEnd]) and (q.thisMove == QTE.NOMOVE) and (m[QTE.moveDeath] == -2) then - q.bShowAction = false - q.lvlState = QTE.branch04 - elseif (q.currentFrame > m[QTE.inputFrmEnd]) and (m[QTE.correctMove] ~= QTE.SKIP) and (m[QTE.correctMove] ~= QTE.WAY) and (m[QTE.correctMove] ~= QTE.WAYOUT) then - q.iPauseFrame = m[QTE.inputFrmEnd] - q.bShowAction = false - q.lvlState = QTE.branch02 - elseif q.currentFrame > m[QTE.inputFrmEnd] then - q.bShowAction = false - Q.addPoints(q, 0, q.currentMove) - if (m[QTE.correctMove] == QTE.WAY) and (m[QTE.moveDeath] > 0) then - q.iPath = m[QTE.moveDeath] - q.lvlState = QTE.lvlPlayRest - elseif m[QTE.correctMove] == QTE.WAYOUT then - q.iPath = m[QTE.moveDeath] - q.lvlState = QTE.branch12 + q.qteFns.judge(q) + elseif late then + -- pendingIsUnanswered: a move still pending when its window closes counts as none. + local pending = (q.thisMove == q.NOMOVE) or ((q.thisMove == q.MOVEPENDING) and Q.option(q, "pendingIsUnanswered", false)) + local jump = (kind == q.SKIP) or (kind == q.WAY) or (kind == q.WAYOUT) or (kind == q.TOSCENE) or (kind == q.TOLEVEL) + + if pending and ((m[QTE.moveDeath] == -1) or (m[QTE.moveDeath] == -3)) then + q.bShowAction = false + q.lvlState = QTE.lvlPlayRest + elseif pending and (m[QTE.moveDeath] == -2) then + q.bShowAction = false + q.lvlState = QTE.branch04 + elseif not jump then + q.iPauseFrame = m[QTE.inputFrmEnd] + q.bShowAction = false + q.lvlState = QTE.branch02 else - q.lvlState = QTE.lvlPlayRest + q.bShowAction = false + Q.addPoints(q, 0, q.currentMove) + if (kind == q.WAY) and (m[QTE.moveDeath] > 0) then + q.iPath = m[QTE.moveDeath] + q.lvlState = QTE.lvlPlayRest + elseif kind == q.WAYOUT then + q.iPath = m[QTE.moveDeath] + q.lvlState = QTE.branch12 + elseif kind == q.TOSCENE then + q.bScnJump = true + q.iPath = m[QTE.moveDeath] + q.lvlState = QTE.branch12 + elseif kind == q.TOLEVEL then + q.bLvlJump = true + q.iPath = m[QTE.moveDeath] + q.lvlState = QTE.branch12 + else + q.lvlState = QTE.lvlPlayRest + end end else q.thisMove = Q.scanInput(q) + Q.checkTilt(q, m, kind) end elseif q.lvlState == QTE.lvlPlayDeath then + if managedHud then + if q.dip_Display == 1 then + q.bShowScore, q.bShowLives = true, true + else + q.bShowScore, q.bShowLives = false, false + end + end if q.currentFrame >= q.iFrameEnd then - if q.ShowSupDeath and (q.dip_GameType ~= 3) then + q.iTilt = 0 + q.bShowWarnTilt, q.bShowTilt = false, false + if (q.i2P or 0) > 0 then + -- Two players take turns at each death. + q.i2P = (q.i2P == 1) and 2 or 1 + q.lvlState = QTE.lvlSetup + q.currentLevel = (q.b1PEnd and q.b2PEnd) and q.level2PEnd or q.level2P + elseif managedHud and (q.dip_Display == 2) and (q.iLives > 0) and (not q.ShowSupDeath) and (not q.ShowResurrect) then + discSkipToFrame(q.frameHints) + discPause() + Q.timerON(q, 3) + q.lvlState = q.branch13 + elseif q.ShowSupDeath and (q.dip_GameType ~= 3) then Q.setupClip(q, q.offsetSupDeath, q.offsetSupDeathEnd) q.lvlState = QTE.branch07 else - Q.afterDeath(q, thisLevel) + Q.afterDeath(q, thisLevel, true) end end elseif q.lvlState == QTE.lvlEnd then @@ -7117,7 +7572,7 @@ function Q.doLevel(q) q.currentLevel = Q.newScore(q, q.iScore) and QTE.levelHighScore or QTE.levelGameOver end else - if Q.beatLevel(q, thisLevel) then + if Q.beatLevel(q, thisLevel) and not q.bScnJump then q.stage[thisLevel][QTE.BEATSTATUS] = true q.iScene = 0 Q.addPoints(q, q.SCORELEVEL, 0) @@ -7127,26 +7582,34 @@ function Q.doLevel(q) q.iBonus = q.iBonus + q.PERFECTBONUS end q.bSkipIntroClip = false - q.iLiveSave, q.iScoreSave, q.bAllowSave = q.iLives, q.iScore, true - q.bRes = true - if (q.ShowLvlClear or (q.dip_GameType == 2)) and (q.dip_GameType ~= 3) then + q.iLiveSave, q.iScoreSave = q.iLives, q.iScore + q.bAllowSave, q.bRes = true, true + if (q.ShowLvlClear or (q.dip_GameType == 2)) and (q.dip_GameType ~= 3) and (q.dip_GameType ~= Q.option(q, "deathCountGameType", nil)) then Q.sound(q, "sndclear") q.currentLevel = QTE.levelFinish else q.iScoreTemp, q.iBonus = 0, 0 if q.dip_PlayStyle == 3 then q.currentLevel = QTE.levelMap + elseif Q.option(q, "levelSelect", false) and (q.dip_PlayStyle == 4) then + q.dip_StartLevel = q.dip_StartLevel + 1 + q.iScene = 0 + Q.toLevelSelect(q) else Q.nextLevel(q, q.iLevel) q.currentLevel = QTE.levelNormal end end else - if q.iPath ~= 0 then - q.iScene = q.iPath - q.iPath = 0 + -- wayOutPlaysTarget: a way out plays the scene it names, done or not; otherwise + -- it goes there when that scene is still to do, and on past it when it is done. + local target = q.iPath + local playsTo = Q.option(q, "wayOutPlaysTarget", false) and (target ~= 0) + + if (target ~= 0) and not q.bLvlJump then + q.iScene = playsTo and (target - 1) or target end - if not q.scene[thisLevel][q.iScene][QTE.SCENECOMPLETE] then + if (not q.scene[thisLevel][q.iScene][QTE.SCENECOMPLETE]) and not playsTo then if q.dip_Rewind == 0 then q.bSwap = true end @@ -7154,15 +7617,20 @@ function Q.doLevel(q) q.iScene = q.iScene - 1 end else - q.iLiveSave, q.iScoreSave, q.bAllowSave, q.bSave = q.iLives, q.iScore, true, false + q.iLiveSave, q.iScoreSave = q.iLives, q.iScore + q.bAllowSave, q.bSave = true, false + if q.bLvlJump then + Q.nextLevel(q, q.iLevel) + q.currentLevel = QTE.levelNormal + end end + q.iPath = 0 if (q.dip_Rewind == 0) or (q.dip_Rewind == 2) then Q.levelReplay(q) end end - q.bShowLives, q.bShowLvl, q.bShowAction = false, false, false - q.bTestMash, q.bTestMashL, q.bTestMashR, q.bTestRunL, q.bTestRunR, q.bTestHold = false, false, false, false, false, false - q.iMash, q.iMulti, q.iLoopStep, q.iLenHold = 0, 0, 0, 0 + q.bShowLives, q.bShowLvl, q.bShowAction, q.bShowNext = false, false, false, false + q.qteFns.resetJudge(q) end end end @@ -7209,31 +7677,44 @@ function Q.doFinish(q) end +-- A continue taken: the scene lost is played again, or the arcade edition starts its stages over. +function Q.stepBack(q) + if q.arcadeEdition then + Q.initStages(q) + q.iScene, q.currentMove = 0, 1 + elseif q.iScene > 0 then + q.iScene = q.iScene - 1 + end +end + + function Q.doContinue(q) if q.lvlState == QTE.lvlSetup then Q.setupClip(q, q.offsetContinue, q.offsetContinueend) - q.bShowLives, q.bShowLvl, q.bShowScore, q.bShowAction = false, false, false, false - q.bShowCredits = true - q.lvlState = QTE.lvlRunning + q.bShowLives, q.bShowLvl, q.bShowScene, q.bShowScore, q.bShowAction, q.bShowNext = false, false, false, false, false, false + q.bShowCredits, q.bTestMash, q.iMash = true, false, 0 + q.iTilt = 0 + q.bShowWarnTilt, q.bShowTilt = false, false + q.lvlState = QTE.lvlRunning elseif q.lvlState == QTE.lvlRunning then - if q.currentFrame >= q.iFrameEnd then + if Q.clipEnded(q) then q.lvlState = QTE.lvlEnd elseif q.p1START1 then q.p1START1 = false - if (q.iCredits > 0) or (q.dip_CoinsPerCredit == QTE.DOPT_FREEPLAY) then + if (q.iCredits > 0) or (q.dip_CoinsPerCredit == q.DOPT_FREEPLAY) then q.bOneDiff, q.bSkipIntroClip = false, true - if q.iScene > 0 then - q.iScene = q.iScene - 1 - end - Q.startGame(q) + q.qteFns.stepBack(q) + q.qteFns.startGame(q) end - elseif q.p1BUTTON2 then + elseif q.p1BUTTON2 and Q.option(q, "continueDeclines", true) then + -- continueDeclines: BUTTON2 turns the continue down. q.p1BUTTON2 = false q.lvlState = QTE.lvlEnd end elseif q.lvlState == QTE.lvlEnd then - q.lvlState = QTE.lvlSetup - if Q.newScore(q, q.iScore) then + q.lvlState = QTE.lvlSetup + q.bSkipIntroClip = false + if q.qteFns.newScore(q, q.iScore) then q.currentLevel = QTE.levelHighScore q.bGOAlt = true else @@ -7245,21 +7726,28 @@ end function Q.doGameOver(q) if q.lvlState == QTE.lvlSetup then - q.bShowLives, q.bShowLvl, q.bShowScore, q.bShowCredits, q.bShowAction = false, false, false, false, false - q.bOneDiff = true + q.bShowLives, q.bShowLvl, q.bShowScene, q.bShowScore, q.bShowCredits, q.bShowAction, q.bShowNext = false, false, false, false, false, false, false + q.bTestMash, q.iMash = false, 0 + q.iTilt = 0 + q.bShowWarnTilt, q.bShowTilt = false, false + -- diffChoiceAfterGameOver: the next game offers the difficulty again, even after a + -- continue turned it off. + if Q.option(q, "diffChoiceAfterGameOver", true) then + q.bOneDiff = true + end if q.bGOAlt then Q.setupClip(q, q.offsetGameOverAlt, q.offsetGameOverAltend) q.bGOAlt = false - elseif (not Q.karis332(q)) and (q.currentFrame == q.offsetContinueend) then - -- 3.31c plays on from the continue clip when the game over follows it on the disc; - -- 3.32b always seeks. + elseif (Q.option(q, "gameOverAfterContinue", "playOn") == "playOn") and (q.currentFrame == q.offsetContinueend) then + -- playOn: the game over plays on from the continue clip when it follows it on the + -- disc; seek: it is always sought. q.iFrameEnd = q.offsetGameOverend else Q.setupClip(q, q.offsetGameOver, q.offsetGameOverend) end q.lvlState = QTE.lvlRunning elseif q.lvlState == QTE.lvlRunning then - if q.currentFrame >= q.iFrameEnd then + if Q.clipEnded(q) then q.bShowScore = false q.lvlState = QTE.lvlEnd end @@ -7298,8 +7786,46 @@ function Q.moveFrameDiff(q) end +-- To the style's level select. +function Q.toLevelSelect(q) + q.altState = QTE.lvlSetup + q.currentLevel = q.levelSelect +end + + -- The in-game difficulty screen: a still per difficulty, the stick to choose, the action button -- or thirty seconds to take it. +-- Whether the game type chosen is one that picks its level (levelSelect: the style has a level +-- select). +function Q.levelSelectType(q) + return Q.option(q, "levelSelect", false) and ((q.dip_GameType == 2) or (q.dip_GameType == 4)) +end + + +-- The lives, and the life bar's floor and bonus, for the game type and difficulty chosen. +function Q.livesForType(q) + if q.dip_GameType == 1 then + q.iLives = 1 + if q.dip_Difficulty == 0 then + q.BarMinT = q.BarMin + if q.BarBonus > 0 then + q.BarBonusT = q.BarBonus - 1 + end + elseif q.dip_Difficulty == 1 then + q.BarMinT, q.BarBonusT = q.BarMin, q.BarBonus + elseif q.dip_Difficulty == 2 then + q.BarMinT, q.BarBonusT = q.BarMin + 1, q.BarBonus + else + q.BarMinT, q.BarBonusT = q.BarMin + 1, q.BarBonus + 1 + end + elseif q.dip_GameType == 3 then + q.iLives = 1 + else + q.iLives = q.dip_LivesPerCredit + end +end + + function Q.doDiffSelect(q) if q.altState == QTE.lvlSetup then q.bShowScore, q.bShowLives, q.bIgnoreJoy = false, false, false @@ -7319,33 +7845,18 @@ function Q.doDiffSelect(q) Q.moveFrameDiff(q) end elseif q.altState == QTE.lvlEnd then - if q.dip_GameType == 1 then - q.iLives = 1 - if q.dip_Difficulty == 0 then - q.BarMinT = q.BarMin - if q.BarBonus > 0 then - q.BarBonusT = q.BarBonus - 1 - end - elseif q.dip_Difficulty == 1 then - q.BarMinT, q.BarBonusT = q.BarMin, q.BarBonus - elseif q.dip_Difficulty == 2 then - q.BarMinT, q.BarBonusT = q.BarMin + 1, q.BarBonus - else - q.BarMinT, q.BarBonusT = q.BarMin + 1, q.BarBonus + 1 - end - elseif q.dip_GameType == 3 then - q.iLives = 1 - else - q.iLives = q.dip_LivesPerCredit - end + Q.livesForType(q) if (q.offsetIntroGame ~= 0) and (q.dip_StartScene == 1) then Q.setupClip(q, q.offsetIntroGame, q.offsetIntroGameend) q.lvlState = QTE.branch11 else q.lvlState = QTE.lvlSetup end + -- levelSelect: the game types that choose their level go to the style's level select. if (q.dip_PlayStyle == 3) and (q.MapStart == 0) then q.currentLevel = QTE.levelMap + elseif Q.levelSelectType(q) or (Q.option(q, "levelSelect", false) and (q.dip_PlayStyle == 4)) then + Q.toLevelSelect(q) else q.currentLevel = QTE.levelNormal end @@ -7353,15 +7864,79 @@ function Q.doDiffSelect(q) end --- The high score board: its clips, the name left unentered (what is typed is not play), and --- on to the game over. +-- The letter grid a name is spelled on: 42 letters, digits, and marks, 9 to a row, then CLR +-- (one letter back) and END. +local NAME_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ@123456789?!# " +local NAME_CLEAR = 43 +local NAME_END = 44 +local NAME_ROW = 9 +local NAME_REPEAT = 0.2 -- Seconds a move on the grid holds off the next. +local NAME_DOWN_TO = 35 -- The last cell DOWN moves a whole row from. +-- Where UP and DOWN go from the cells of the short last row and the first row's ends, as the +-- games move there. +local NAME_DOWN = { [36] = 44, [37] = 1, [38] = 2, [39] = 3, [40] = 4, [41] = 5, [42] = 6, [43] = 7, [44] = 9 } +local NAME_UP = { [1] = 37, [2] = 38, [3] = 39, [4] = 40, [5] = 41, [6] = 42, [7] = 43, [8] = 43, [9] = 44, [43] = 34, [44] = 36 } + + +-- A press on the name's grid: the stick moves the cursor, BUTTON1 takes the letter under it (up +-- to three), CLR takes one back, END puts the result on the board and shows the rankings. +function Q.nameEntryInput(q) + if q.p1LEFT then + q.iCursor = q.iCursor - 1 + Q.sound(q, "sndright") + elseif q.p1RIGHT then + q.iCursor = q.iCursor + 1 + Q.sound(q, "sndright") + elseif q.p1DOWN then + Q.sound(q, "sndright") + q.iCursor = (q.iCursor <= NAME_DOWN_TO) and (q.iCursor + NAME_ROW) or NAME_DOWN[q.iCursor] or q.iCursor + elseif q.p1UP then + Q.sound(q, "sndright") + q.iCursor = ((q.iCursor > NAME_ROW) and (q.iCursor < NAME_CLEAR)) and (q.iCursor - NAME_ROW) or NAME_UP[q.iCursor] or q.iCursor + elseif q.p1BUTTON1 then + if q.iCursor < NAME_CLEAR then + Q.sound(q, "sndcredit") + if #q.sName < 3 then + q.sName = q.sName .. NAME_LETTERS:sub(q.iCursor, q.iCursor) + end + if #q.sName == 3 then + q.iCursor = NAME_END + end + elseif q.iCursor == NAME_CLEAR then + Q.sound(q, "snddeath") + q.sName = q.sName:sub(1, -2) + else + Q.boardUpdate(q, q.sName, q.iScore) + Q.setupClip(q, q.offsetRankings, q.offsetRankingsend) + q.lvlState = QTE.branch02 + end + end + if q.iCursor > NAME_END then + q.iCursor = 1 + elseif q.iCursor < 1 then + q.iCursor = NAME_END + end + if q.p1UP or q.p1DOWN or q.p1LEFT or q.p1RIGHT or q.p1BUTTON1 then + q.bIgnoreJoy = true + Q.joyDelayON(q, NAME_REPEAT) + end + q.p1UP, q.p1DOWN, q.p1LEFT, q.p1RIGHT, q.p1BUTTON1 = false, false, false, false, false +end + + +-- The high score board: its clips, the name left unentered (what is typed is not play), the +-- rankings, and on to the game over -- or the attract, for the game type without one. function Q.doHighScore(q) if q.lvlState == QTE.lvlSetup then - q.bShowLives, q.bShowScore, q.bShowCredits, q.bShowAction = false, false, false, false + q.bShowLives, q.bShowScore, q.bShowCredits, q.bShowAction, q.bShowNext, q.bShowLCD = false, false, false, false, false, false + q.bIgnoreJoy = false + q.iTilt = 0 + q.bShowWarnTilt, q.bShowTilt = false, false + q.iCursor, q.sName = 1, "" Q.setupClip(q, q.offsetNewHScore, q.offsetNewHScoreend) q.lvlState = QTE.branch01 elseif q.lvlState == QTE.branch01 then - if (q.currentFrame == q.iFrameEnd) or q.p1BUTTON1 then + if (q.currentFrame >= q.iFrameEnd) or q.p1BUTTON1 then q.p1BUTTON1 = false if q.iFrameEnd + 1 ~= q.offsetEnterHScore then Q.setupClip(q, q.offsetEnterHScore, q.offsetEnterHScoreend) @@ -7371,22 +7946,48 @@ function Q.doHighScore(q) q.lvlState = QTE.lvlRunning end elseif q.lvlState == QTE.lvlRunning then - if q.currentFrame == q.iFrameEnd then - if q.iFrameEnd + 1 ~= q.offsetRankings then - Q.setupClip(q, q.offsetRankings, q.offsetRankingsend) + -- nameEntry: the player spells a name on the letter grid while the clip plays; the + -- clip's end, or END, puts the result on the board. + local entering = Q.option(q, "nameEntry", true) + + if entering and (q.currentFrame < q.iFrameEnd) then + if q.bIgnoreJoy then + if Q.joyDelayDue(q) then + q.bIgnoreJoy = false + end else - q.iFrameEnd = q.offsetRankingsend + Q.nameEntryInput(q) end - q.lvlState = QTE.branch02 + return end - elseif q.lvlState == QTE.branch02 then - if q.currentFrame == q.iFrameEnd then + if q.currentFrame >= q.iFrameEnd then + if entering then + Q.boardUpdate(q, q.sName, q.iScore) + end + -- rankingsAfterBoard: playOn plays on into the rankings when they follow the board on + -- the disc; seek always seeks them. + if (Q.option(q, "rankingsAfterBoard", "playOn") == "playOn") and (q.iFrameEnd + 1 == q.offsetRankings) then + q.iFrameEnd = q.offsetRankingsend + else + Q.setupClip(q, q.offsetRankings, q.offsetRankingsend) + end + -- A game type that chooses its level waits on the rankings in a state of its own. + q.lvlState = Q.levelSelectType(q) and QTE.branch03 or QTE.branch02 + end + elseif (q.lvlState == QTE.branch02) or (q.lvlState == QTE.branch03) then + if q.currentFrame >= q.iFrameEnd then q.lvlState = QTE.lvlEnd end elseif q.lvlState == QTE.lvlEnd then - q.bGOAlt = true - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelGameOver + Q.boardsSave(q) + q.lvlState = QTE.lvlSetup + -- noGameOverGameType: the game type that goes from the board back to the attract. + if q.dip_GameType == Q.option(q, "noGameOverGameType", nil) then + q.currentLevel = QTE.levelIntro + else + q.bGOAlt = true + q.currentLevel = QTE.levelGameOver + end end end @@ -7402,7 +8003,7 @@ function Q.gameFile(q, name) if (mine ~= "") and (name:sub(1, #mine + 1) == mine .. "/") then return name:sub(#mine + 2) end - -- A game that names its files by their place in the Hypseus tree (singe//...): the + -- A game that names its files by their place in a singe/<title>/ tree: the -- port carries them flat beside itself. if stem and (name:sub(1, #stem + 7) == "singe/" .. stem .. "/") then return name:sub(#stem + 8) @@ -7472,7 +8073,1564 @@ local function isHandle(handle) end --- The framework's font colours (the Karis toolbox's setFontColor), for the loops that do not +-- What the games change as they draw, after the frame's play, done here where the qteHud look +-- reads it after: the level name goes once play is past its first move or its time is up, and +-- the autosave disk shows two seconds (on the shared timer and altState, as the games time it; +-- it first shows the frame after it is set, and not at all unless the HUD is on, dip_Display 0). +function Q.afterStep(q) + if q.bShowLvl and q.ShowLevel and ((q.currentFrame == q.Tlimit) or ((q.currentMove or 0) > 1)) then + q.bShowLvl = false + end + -- The slots' disks, then the autosave's (only with the HUD on), each on the shared timer. + q.diskVisible = nil + for _, disk in ipairs({ "1", "2", "3", "A" }) do + if q["bShowDisk" .. disk] and ((disk ~= "A") or (q.dip_Display == 0)) then + if q.altState == QTE.branch01 then + Q.timerON(q, 2) + q.altState = QTE.branch02 + elseif q.altState == QTE.branch02 then + if Q.timerDue(q) then + q["bShowDisk" .. disk] = false + else + q.diskVisible = q.diskVisible or disk + end + end + end + end +end + + +-- Leaving play for a screen (SERVICE, TEST, the exit chord): the game autosaved (not in the +-- survivor game type, nor past the scene's last move) to come back to; the chord's autosave also +-- restarts the disk's timing. +function Q.leavePlay(q, chord) + if (q.currentLevel == QTE.levelNormal) or (q.currentLevel == QTE.levelMap) then + q.bInPlayExit = true + if (q.currentLevel == QTE.levelMap) or ((q.currentMove <= q.totalMoves) and (q.dip_GameType ~= 3)) then + Q.autoSave(q, 4) + if chord then + q.altState = QTE.branch01 + end + end + end +end + + +-- The loop's state as Forge's vars, for a description's own rules and HUD to read. +function Q.publishVars(q) + AUTHOR_VARS.score = q.iScore + AUTHOR_VARS.lives = q.iLives + AUTHOR_VARS.credits = q.iCredits + AUTHOR_VARS.level = q.iLevel + if q.bShowAction and q.move[q.currentMove] then + AUTHOR_VARS.prompt = q.move[q.currentMove][QTE.correctMove] + else + AUTHOR_VARS.prompt = "" + end +end + + +-- The games' start, after the title clip and again after the options screen: the attract loop +-- from its first state, the credits and score cleared, the HUD back to the credits alone, the +-- life bar's and the difficulty's settings taken up, the settings' language, extra clips, and +-- record, and the overlay laid out again (the options may have changed its resolution). +function Q.initJob(q) + local penalties = { [0] = 0, [1] = q.PenalNormal, [2] = q.PenalHard, [3] = q.PenalExtreme } + + q.gameflow = "running" + q.currentLevel = QTE.levelIntro + q.lvlState = QTE.lvlSetup + q.altState = QTE.lvlSetup + q.iCoins, q.iScore, q.iScoreTemp, q.iBonus, q.iScene = 0, 0, 0, 0, 0 + q.bShowCredits = true + q.bShowScore, q.bShowLives, q.bShowAction, q.bShowLvl, q.bShowSkip = false, false, false, false, false + q.bShowDisk1, q.bShowDisk2, q.bShowDisk3, q.bShowDiskA = false, false, false, false + q.BarMinT, q.BarBonusT = q.BarMin, q.BarBonus + q.iPenal = penalties[q.dip_Difficulty] or q.iPenal + Q.setLang(q) + Q.applyExtras(q) + if q.ShowTop then + q.bShowTop = true + end + Q.applyResolution(q) +end + + +-- ===== Saves ===================================================================================== +-- +-- The games' save slots: three a player saves to, the autosave (4) the games write as a level +-- clears and before a service screen from play, and two more of the held style's. A save is +-- { line = { level, scene, move, lives or life bar, difficulty, score, game type, scenes played, +-- deaths in the scene, deaths in all, play style }, levels = { { order, started, beaten, deaths }, +-- ... } }. They are the description's (what the game shipped with) until the game writes one, +-- which keepsSaves keeps in the engine's save store for the next run. + +local SAVE_SLOTS = 6 +local SAVE_KEY = "forge.save" + + +-- The slots, each the one an earlier run saved or the description's. +function Q.savesLoad(q) + q.saves = {} + for slot = 1, SAVE_SLOTS do + local text = saveGet(SAVE_KEY .. slot) + local chunk = (type(text) == "string") and load(text, "save", "t", {}) or nil + local saved = chunk and chunk() or nil + + q.saves[slot] = (type(saved) == "table") and saved or (q.snapshot.saves or {})[slot] + end +end + + +-- A save's line into a slot, with the levels as they stand, in their order, each started as it is +-- beaten (as the games write them); kept for the next run under keepsSaves. +function Q.saveWrite(q, slot, line) + local levels = {} + + for k = 1, q.finalstage do + local stage = q.stage[q.LvlOrder[k]] + + levels[k] = { q.LvlOrder[k], stage[QTE.BEATSTATUS] == true, stage[QTE.BEATSTATUS] == true, stage[QTE.DEATHCOUNT] } + end + q.saves[slot] = { line = line, levels = levels } + if Q.option(q, "keepsSaves", true) then + saveSet(SAVE_KEY .. slot, "return " .. sourceOf(q.saves[slot])) + end +end + + +-- Where the game stands, into a slot, as the games' autoSave writes it. +function Q.autoSave(q, slot) + Q.saveWrite(q, slot, { q.iLevel, q.iScene, q.bCheckMove and (q.currentMove + 1) or q.currentMove, (q.dip_GameType == 1) and q.iLifeBar or q.iLives, q.dip_Difficulty, q.iScore, q.dip_GameType, q.iScPlayed, q.iScDeath, q.iTotDeath, q.dip_PlayStyle }) +end + + +function Q.loadSave(q, thisSlot) + local save = q.saves[thisSlot] + + if save == nil then + q.lvlState = QTE.lvlEnd + return + end + local line = save.line + + q.scene, q.stage = {}, {} + for k = 1, q.finalstage do + q.scene[k] = {} + q.stage[k] = { false, false, 0 } + for i = 1, q.Level[k][QTE.TOTALSCENES] do + q.scene[k][i] = { i, false } + end + end + if (thisSlot == 5) or (thisSlot == 6) then + q.iLevel, q.iScene = tonumber(line[1]), tonumber(line[2]) + else + q.dip_StartLevel, q.dip_StartScene = tonumber(line[1]), tonumber(line[2]) + end + q.currentMove = tonumber(line[3]) + q.dip_GameType = tonumber(line[7]) + if q.dip_GameType == 1 then + q.iLifeBar, q.iLives = tonumber(line[4]), 1 + else + q.iLives = tonumber(line[4]) + end + q.iScPlayed, q.iScDeath, q.iTotDeath = tonumber(line[8]), tonumber(line[9]), tonumber(line[10]) + q.dip_PlayStyle, q.dip_Difficulty, q.iScore = tonumber(line[11]), tonumber(line[5]), tonumber(line[6]) + for k, entry in ipairs(save.levels) do + q.LvlOrder[k] = entry[1] + if entry[2] then + q.stage[k][QTE.LEVELSTARTED] = true + end + if entry[3] then + q.stage[k][QTE.BEATSTATUS] = true + end + q.stage[k][QTE.DEATHCOUNT] = entry[4] + end + for k = 1, q.finalstage do + if q.stage[k][QTE.BEATSTATUS] then + for i = 1, q.Level[k][QTE.TOTALSCENES] do + q.scene[k][i] = { i, true } + end + elseif q.stage[k][QTE.LEVELSTARTED] and (q.dip_StartScene ~= 1) then + for i = 1, q.dip_StartScene - 1 do + q.scene[k][i] = { i, true } + end + end + end + if q.AllowSecret and q.Level[QTE.levelSecret] then + q.stage[QTE.levelSecret] = { false, false, 0 } + q.scene[QTE.levelSecret] = {} + for i = 1, q.Level[QTE.levelSecret][QTE.TOTALSCENES] do + q.scene[QTE.levelSecret][i] = { i, false } + end + end + if (q.dip_StartLevel == 1) and (q.dip_StartScene == 1) and (q.currentMove == 1) and (thisSlot < 5) then + q.lvlState = QTE.lvlEnd + elseif (q.dip_StartScene <= 0) and (q.dip_PlayStyle == 3) then + q.iLiveSave, q.iScoreSave = q.iLives, q.iScore + q.qteFns.startMap(q) + else + q.iLiveSave, q.iScoreSave = q.iLives, q.iScore + q.qteFns.startSave(q) + end + if q.loadSavePlus then + q.loadSavePlus(thisSlot) + end +end + + +-- The windows style's start from a save: the level and scene it stood at, the counts of the +-- scene's deaths cleared, the life bar's settings for the difficulty, the record for the game type, +-- and the HUD by the display setting. +function Q.startSave(q) + if q.currentMove ~= 1 then + q.bSave = true + end + q.iContinues = 0 + q.iLevel = q.dip_StartLevel + q.iScene = q.dip_StartScene - 1 + q.iScoreTemp = q.iScore + q.iScPlayed, q.iScDeath, q.iTotDeath = 0, 0, 0 + if q.dip_GameType == 1 then + Q.livesForType(q) + end + q.iTop = Q.topFor(q) + q.currentLevel = QTE.levelNormal + q.lvlState = QTE.lvlSetup + Q.startSaveHud(q) +end + + +-- The windows style's start from a save made on the map: the map, the lives a credit gives. +function Q.startMap(q) + q.iContinues, q.iScene = 0, 0 + q.iLevel = q.dip_StartLevel + q.iScoreTemp = q.iScore + q.iScPlayed, q.iScDeath, q.iTotDeath = 0, 0, 0 + if q.dip_GameType == 1 then + Q.livesForType(q) + else + q.iLives = q.dip_LivesPerCredit + end + q.iTop = Q.topFor(q) + q.currentLevel = QTE.levelMap + q.lvlState = QTE.lvlSetup + Q.startSaveHud(q) +end + + +-- The HUD as a game from a save starts it: the score, lives, and level by the display setting, +-- everything else off. +function Q.startSaveHud(q) + if q.dip_Display == 0 then + q.bShowScore, q.bShowLives, q.bShowLvl = true, true, true + q.bShowTop = q.ShowTop and true or false + else + q.bShowScore, q.bShowLives, q.bShowLvl, q.bShowTop = false, false, false, false + end + q.bShowGet, q.bShowSkip, q.bShowAction, q.bShowCredits, q.bShowLCD, q.bResetContinue, q.bExtendedPlay = false, false, false, false, false, false, false +end + + +-- ===== The service screens ===================================================================== +-- +-- The options screen SERVICE opens from the attract loop: fifteen rows on the options still, UP +-- and DOWN between them, RIGHT or BUTTON1 to the next value and LEFT to the one before (each +-- move holding off the next a quarter of a second), START to keep them and leave. The values +-- change by the games' own rules, which tie some to others (a game type sets the hints, the +-- rewind, and the difficulty screen; the level to start at resets the scene). What is kept is +-- the dips, written through the engine's save store (Q.configSave) and read at attach. + +local SERVICE_ROWS = 15 +local SERVICE_REPEAT = 0.25 -- Seconds a move on the screen holds off the next. +local SERVICE_GAME_TYPES = { [0] = "Normal", "Life Bar", "Percent", "Survivor", "Kid (Action Only)", "Watch Movie" } +local SERVICE_DIFFICULTY = { [0] = "Easy", "Normal", "Hard", "Extreme", "In-Game" } +local SERVICE_STYLES = { [0] = "Normal", "Random", "Tiers", "Map" } +local SERVICE_REWINDS = { [0] = "Scene", "Last Move", "Level", "Next Move" } +local SERVICE_PROMPTS = { [0] = "Nothing", "Sound", "Guess", "Cliff", "Normal", "Normal+Sound" } +local SERVICE_MASHES = { [0] = "Good Stick", "Bad Stick", "Two Buttons", "Simple Mash" } +local SERVICE_LABELS = { "Game Options", "Game Type", "Difficulty", "Play Style", "Start at Level", "Start at Scene", "Death Rewind", "Coins per Credit", "Lives per Credit", "Allow Continue?", "Show Moves", "Show Hints", "Run Action with" } + + +-- A value one step on, round from the last to the first. +local function serviceStep(value, step, first, last) + value = value + step + if value > last then + return first + elseif value < first then + return last + end + + return value +end + + +-- A game type chosen, with what it ties to it: every type but the normal one turns the hints +-- off, the life bar turns the rewind to the scene, and the difficulty screen goes to the in-game +-- choice (or normal), none for the kid's type, and normal to watch the movie. +local function serviceGameType(q, gameType) + q.dip_GameType = gameType + if gameType ~= 0 then + q.dip_Hints = false + end + if gameType == 1 then + q.dip_Rewind = 0 + end + if gameType == 4 then + q.dip_Diffshow = 0 + elseif gameType == 5 then + q.dip_Diffshow = 1 + else + q.dip_Diffshow = q.IngameDiffchoice and 4 or 1 + end +end + + +-- The play style one step on or back among those the game allows (random, tiers, map; normal +-- always), the level and scene to start at going back to the first on leaving or reaching the +-- normal one. +local function servicePlayStyle(q, step) + local allowed = { [0] = true, [1] = (q.AllowRandom == true), [2] = (q.AllowTier == true), [3] = (q.AllowMap == true) } + local style = q.dip_PlayStyle + + repeat + style = serviceStep(style, step, 0, 3) + until allowed[style] + if (q.dip_PlayStyle == 0) or (style == 0) then + q.dip_StartLevel, q.dip_StartScene = 1, 1 + end + q.dip_PlayStyle = style +end + + +-- A screen's last two rows, the same on every screen: "Default" (or "Restart" from play), and +-- EXIT (or back to the game), which leaves. +local function serviceLastRows(q, row, rows, state) + if row == rows - 1 then + if q.bInPlayExit then + Q.applyResolution(q) + q.qteFns.startGame(q) + else + Q.serviceDefaults(q) + end + elseif row == rows then + q[state] = QTE.lvlEnd + end +end + + +-- A row's value one step on (1) or back (-1), by the games' rules; the first row opens the +-- graphics screen going on and the performance screen going back, the last two restore the +-- defaults and leave. +function Q.serviceChange(q, row, step) + local gameType = q.dip_GameType + + if row == 1 then + if step > 0 then + q.optSelG, q.altState, q.currentLevel = 1, QTE.lvlRunning, QTE.levelGraphic + else + q.optSelP, q.altState2, q.currentLevel = 1, QTE.lvlRunning, QTE.levelPerf + end + elseif row == 2 then + serviceGameType(q, serviceStep(gameType, step, 0, 5)) + elseif (row == 3) and (gameType < 4) then + q.dip_Diffshow = serviceStep(q.dip_Diffshow, step, 0, q.IngameDiffchoice and 4 or 3) + elseif (row == 4) and (gameType ~= 2) and (gameType ~= 3) and (gameType ~= 5) then + servicePlayStyle(q, step) + elseif (row == 5) and (gameType ~= 5) and (gameType ~= 3) and (q.dip_PlayStyle == 0) then + q.dip_StartLevel = serviceStep(q.dip_StartLevel, step, 1, q.finalstage) + q.dip_StartScene = 1 + elseif (row == 6) and ((gameType == 0) or (gameType == 1) or (gameType == 4)) and (q.dip_Rewind ~= 2) then + q.dip_StartScene = serviceStep(q.dip_StartScene, step, 1, q.Level[q.dip_StartLevel][QTE.TOTALSCENES]) + elseif (row == 7) and (gameType ~= 1) and (gameType ~= 2) and (gameType ~= 3) and (gameType ~= 5) then + if not q.ForceRewind then + q.dip_Rewind = serviceStep(q.dip_Rewind, step, 0, 3) + end + if q.dip_Rewind == 2 then + q.dip_StartScene = 1 + end + elseif row == 8 then + q.dip_CoinsPerCredit = serviceStep(q.dip_CoinsPerCredit, step, 0, 3) + elseif (row == 9) and (gameType ~= 1) and (gameType ~= 2) and (gameType ~= 3) and (gameType ~= 5) then + q.dip_LivesPerCredit = serviceStep(q.dip_LivesPerCredit, step, 1, 5) + elseif (row == 10) and (gameType ~= 2) and (gameType ~= 3) and (gameType ~= 5) then + q.dip_LimitContinue = serviceStep(q.dip_LimitContinue, step, 0, 4) + elseif (row == 11) and (gameType ~= 5) then + q.dip_ShowAction = serviceStep(q.dip_ShowAction, step, 0, 5) + elseif row == 12 then + -- The games let the kid's type change its hints going back, not going on. + if (gameType ~= 1) and (gameType ~= 2) and (gameType ~= 5) and ((gameType ~= 4) or (step < 0)) then + q.dip_Hints = not q.dip_Hints + end + elseif (row == 13) and (gameType ~= 4) and (gameType ~= 5) then + q.dip_MashtoRun = serviceStep(q.dip_MashtoRun, step, 0, 3) + else + serviceLastRows(q, row, SERVICE_ROWS, "lvlState") + end +end + + +-- The overlay laid out as the games lay theirs: the disc's own size, or half of it at the lower +-- resolution (dip_Res); the HUD's pictures load again for it when it next draws, and whether +-- the resolution has scanlines is known. +function Q.applyResolution(q) + local scan = io.open(q.MYDIR .. (((q.dip_Res or 0) == 0) and "/Overlay/" or "/Overlay/Lores/") .. "scanlines.png", "rb") + + if discGetWidth and (discGetWidth() > 0) then + if (q.dip_Res or 0) == 0 then + overlaySetResolution(discGetWidth(), discGetHeight()) + else + overlaySetResolution(discGetWidth() / 2, discGetHeight() / 2) + end + end + -- Scanlines are the resolution's own picture, where the game has one; once found, the games + -- keep saying they have them, at either resolution. + q.bScan = q.bScan or (scan ~= nil) + if scan then + scan:close() + end + q.hud = nil +end + + +-- The extra clips' combinations: get-ready, the clip after a death, level clear; each pair; +-- all three. +local SERVICE_EXTRAS = { [0] = { false, false, false }, { true, false, false }, { false, true, false }, { false, false, true }, { true, true, false }, { true, false, true }, { false, true, true }, { true, true, true } } + + +-- The next combination of extra clips on (1) or back (-1) whose clips the disc has, round to none; +-- the three show-flags follow it. +local function serviceExtras(q, step) + local has = { (q.offsetGetReadyEnd or 0) - (q.offsetGetReady or 0) > 1, (q.offsetSupDeathEnd or 0) - (q.offsetSupDeath or 0) > 1, (q.offsetClearend or 0) - (q.offsetClear or 0) > 1 } + local extras = q.dip_Extravid or 0 + + repeat + extras = serviceStep(extras, step, 0, 7) + local want = SERVICE_EXTRAS[extras] + until (extras == 0) or (((not want[1]) or has[1]) and ((not want[2]) or has[2]) and ((not want[3]) or has[3])) + q.dip_Extravid = extras + Q.applyExtras(q) +end + + +-- The extra clips the setting turns on (dip_Extravid): the get-ready, the clip after a death, the +-- level clear. The games read it again whenever they read their settings. +function Q.applyExtras(q) + local extras = SERVICE_EXTRAS[q.dip_Extravid or 0] or SERVICE_EXTRAS[0] + + q.ShowResurrect, q.ShowSupDeath, q.ShowLvlClear = extras[1], extras[2], extras[3] +end + + +-- A text colour one step on or back, not the other colour: going on it steps past the other after +-- wrapping (to 13, as the games let it), going back before wrapping. +local function serviceColour(q, name, other, step) + local colour = q[name] + step + + if step > 0 then + if colour > 12 then + colour = 1 + end + if colour == q[other] then + colour = colour + 1 + end + else + if colour == q[other] then + colour = colour - 1 + end + if colour < 1 then + colour = 12 + end + end + q[name] = colour + Q.fontColour(q, colour) +end + + +-- The graphics screen's rows: the first goes to the performance screen on and the options back. +function Q.serviceChangeGraphics(q, row, step) + if row == 1 then + if step > 0 then + q.optSelP, q.altState2, q.currentLevel = 1, QTE.lvlRunning, QTE.levelPerf + else + q.optSel, q.lvlState, q.currentLevel = 1, QTE.lvlRunning, QTE.levelService + end + elseif (row == 2) and (q.dip_GameType ~= 5) then + serviceExtras(q, step) + elseif (row >= 3) and (row <= 6) then + local flag = ({ "ShowTitle", "ShowLCD", "ShowLevel", "ShowTop" })[row - 2] + + q[flag] = not q[flag] + elseif (row >= 7) and (row <= 9) then + local where = ({ "ScorePos", "LivPos", "CoinPos" })[row - 6] + + -- Laid out again when the game next starts, as the games lay it out. + q[where] = serviceStep(q[where], step, 1, 2) + elseif row == 10 then + q.fontType = serviceStep(q.fontType, step, 1, 2) + q.fontSize = (q.fontType == 1) and (q.fontSave or 0) or 0 + q.hud = nil + elseif (row == 11) and (q.fontType == 1) then + -- The size wraps at 8 going on and at -8 going back, to 0 either way. + q.fontSize = q.fontSize + step + if (q.fontSize > 8) or (q.fontSize < -8) then + q.fontSize = 0 + end + q.fontSave = q.fontSize + q.hud = nil + elseif row == 12 then + serviceColour(q, "mycolor1", "mycolor2", step) + elseif row == 13 then + serviceColour(q, "mycolor2", "mycolor1", step) + else + serviceLastRows(q, row, SERVICE_ROWS, "altState") + end +end + + +-- The performance screen's machine settings, by model, going on and going back (the games set the +-- third one's resolution differently the two ways). +local SERVICE_MODELS_ON = { [0] = { 0, 0, 0, 0, 3 }, { 1, 0, 0, 0, 2 }, { 0, 1, 0, 0, 1 }, { 1, 1, 1, 1, 1 } } +local SERVICE_MODELS_BACK = { [0] = { 0, 0, 0, 0, 3 }, { 1, 0, 0, 0, 2 }, { 1, 1, 0, 0, 1 }, { 1, 1, 1, 1, 1 } } +local SERVICE_PERF_ROWS = 10 + + +-- The performance screen's rows: the first goes to the options on and the graphics back. +function Q.serviceChangePerformance(q, row, step) + if row == 1 then + if step > 0 then + q.optSel, q.lvlState, q.currentLevel = 1, QTE.lvlRunning, QTE.levelService + else + q.optSelG, q.altState, q.currentLevel = 1, QTE.lvlRunning, QTE.levelGraphic + end + elseif row == 2 then + local model + + q.dip_Model = serviceStep(q.dip_Model or 0, step, 0, 3) + model = ((step > 0) and SERVICE_MODELS_ON or SERVICE_MODELS_BACK)[q.dip_Model] + q.dip_Res, q.dip_Display, q.dip_ScType, q.dip_LivType, q.dip_FontQ = model[1], model[2], model[3], model[4], model[5] + fontQuality(q.dip_FontQ) + Q.applyResolution(q) + elseif row == 3 then + q.dip_Res = serviceStep(q.dip_Res, step, 0, 1) + Q.applyResolution(q) + elseif row == 4 then + q.dip_Display = serviceStep(q.dip_Display, step, 0, 1) + elseif (row == 5) and q.bScan then + q.dip_Scanlines = serviceStep(q.dip_Scanlines or 0, step, 0, 2) + elseif row == 6 then + q.dip_ScType = serviceStep(q.dip_ScType, step, 0, 1) + elseif row == 7 then + q.dip_LivType = serviceStep(q.dip_LivType, step, 0, 1) + elseif row == 8 then + q.dip_FontQ = serviceStep(q.dip_FontQ, step, 1, 3) + fontQuality(q.dip_FontQ) + else + serviceLastRows(q, row, SERVICE_PERF_ROWS, "altState2") + end +end + + +-- The settings the game shipped with (its default.cfg, the description's defaults), what the +-- screen's "Default" restores, with the font quality and the resolution they set. +function Q.serviceDefaults(q) + for name, value in pairs(q.snapshot.defaults or q.snapshot.dips or {}) do + q[name] = value + end + for _, name in ipairs(QTE_CONFIG_FLAGS) do + q[name] = (q[name] == 1) or (q[name] == true) + end + if q.dip_FontQ then + fontQuality(q.dip_FontQ) + end + Q.applyExtras(q) + Q.applyResolution(q) +end + + +-- A screen of options the shared way: on its state variable, its row, UP and DOWN between the +-- rows, RIGHT or BUTTON1 to the next value and LEFT to the one before (a move holding off the +-- next a quarter of a second), START to leave; a screen of buttonOnly takes BUTTON1 alone and +-- no START. Leaving is the screen's own (leave), or the options' (serviceLeave). +function Q.doServiceScreen(q, screen) + local state = screen.state + local rows = screen.rows or (#screen.labels + 2) + local act = q.p1BUTTON1 or ((not screen.buttonOnly) and (q.p1RIGHT or q.p1LEFT)) + + if q[state] == QTE.lvlSetup then + if screen.setup then + screen.setup(q) + end + q[screen.row] = 1 + q[state] = QTE.lvlRunning + elseif q[state] == QTE.lvlRunning then + if act or q.p1DOWN or q.p1UP then + if q.bIgnoreJoy then + if Q.joyDelayDue(q) then + q.bIgnoreJoy = false + end + else + if q.p1BUTTON1 or (q.p1RIGHT and not screen.buttonOnly) then + q.p1BUTTON1, q.p1RIGHT = false, false + Q.sound(q, "sndcredit") + screen.change(q, q[screen.row], 1) + elseif q.p1LEFT and not screen.buttonOnly then + q.p1LEFT = false + Q.sound(q, "sndcredit") + screen.change(q, q[screen.row], -1) + else + q[screen.row] = serviceStep(q[screen.row], q.p1DOWN and 1 or -1, 1, rows) + q.p1DOWN, q.p1UP = false, false + Q.sound(q, "sndcoin") + end + q.bIgnoreJoy = true + Q.joyDelayON(q, SERVICE_REPEAT) + end + elseif q.p1START1 and not screen.buttonOnly then + q.p1START1 = false + q[state] = QTE.lvlEnd + end + elseif q[state] == QTE.lvlEnd then + (screen.leave or Q.serviceLeave)(q) + end +end + + +-- Leaving an options screen: the settings kept; from play, back to where the game stood when +-- nothing that sets up a game changed (the autosave), else a new game; else the games' start. +function Q.serviceLeave(q) + Q.configSave(q) + if q.bInPlayExit then + q.bInPlayExit = false + if (q.sLevel == q.dip_StartLevel) and (q.sScene == q.dip_StartScene) and (q.sMode == q.dip_PlayStyle) and (q.sType == q.dip_GameType) and (q.sLives == q.dip_LivesPerCredit) then + Q.loadSave(q, 4) + else + Q.applyResolution(q) + q.qteFns.startGame(q) + end + else + q.gameflow = "init" + end +end + + +-- Leaving the save or exit screen: back to the game through the autosave, or the games' start. +local function serviceLeaveToGame(q) + if q.bInPlayExit then + q.bInPlayExit = false + Q.loadSave(q, 4) + else + q.gameflow = "init" + end +end + + +-- The options screen's own start: the options still, the timer off, the HUD off, and from play +-- where the game stood (so leaving can tell whether an option changed it). +local function serviceSetup(q) + discSkipToFrame(q.frameOptions) + discPause() + q.timerLimit = nil + q.bShowLvl, q.bShowScene, q.bShowScore, q.bShowLives, q.bShowSkip = false, false, false, false, false + q.bShowCredits, q.bShowGet, q.bShowLCD, q.bShowAction, q.bIgnoreJoy = false, false, false, false, false + if q.bInPlayExit then + q.sType, q.sMode, q.sLevel, q.sScene, q.sLives = q.dip_GameType, q.dip_PlayStyle, q.iLevel, q.iScene, q.dip_LivesPerCredit + q.dip_StartLevel, q.dip_StartScene = q.iLevel, q.iScene + end +end + + +-- ===== The HUD of a quick-time-event game ==================================================== +-- +-- What the loop's flags say a player sees, drawn by the qteHud look from the loop's state: the +-- score and the record, the lives or the life bar, and the rest as it comes. Laid out as the +-- games lay it out: in lines and columns of an eightieth of the overlay, from the sizes of the +-- game's own pictures (its Overlay folder, Overlay/Lores at the lower resolution) and its font. + +local HUD_LINES = 80 -- The overlay is laid out in eightieths, across and down. +local HUD_POINTS = 90 -- A font's size grows by the overlay's height over this. +local HUD_BLINK = 0.3 -- Seconds a blinking picture is on, then off. +local HUD_SPRITES = { + barFull = "barfull.png", + barNone = "barnone.png", + buttons = "buttons.png", + credit = "credit.png", + scanlines = "scanlines.png", + diskA = "diskA.png", + disk1 = "disk1.png", + disk2 = "disk2.png", + disk3 = "disk3.png", + freePlay = "freeplay.png", + getReady = "getready.png", + insertCoin = "insertcoin.png", + lives = "live.png", + noMoves = "nomoves.png", + scenes = "scene.png", + select = "arrowsel2.png", + selectLeft = "arrowsel1.png", + skip = "skip.png", + specials = "special.png", + stick = "stick.png", + topScore = "topscore.png" +} +-- A move's prompt picture by its kind, in the games' numbering (the QTE table's). +local HUD_PROMPTS = { + [1] = "arrowup.png", [2] = "arrowdown.png", [3] = "arrowleft.png", [4] = "arrowright.png", + [5] = "action.png", [6] = "action2.png", [7] = "action3.png", [8] = "action4.png" +} + + +-- A picture of the game's HUD, or nil when the game has none by that name. +local function hudSprite(h, file) + local handle = io.open(h.dir .. file, "rb") + + if handle == nil then + return nil + end + handle:close() + + return spriteLoad(h.dir .. file) +end + + +-- The HUD's pictures, fonts, and layout, once, when it first draws. +function Q.hudLoad(q) + local h = { sprite = {}, number = {} } + local lowRes = (q.dip_Res or 0) ~= 0 + local points = (lowRes and ((q.fontSize or 0) / 2) or (q.fontSize or 0)) + overlayGetHeight() / HUD_POINTS + local font = q.MYDIR .. ((q.fontType == 1) and "/Fonts/font.ttf" or "/Fonts/hudDefault.ttf") + local zero = nil + + h.shown = {} + h.dir = q.MYDIR .. (lowRes and "/Overlay/Lores/" or "/Overlay/") + h.OVLW = overlayGetWidth() + h.OVLH = overlayGetHeight() + h.LINEW = h.OVLW / HUD_LINES + h.LINEH = h.OVLH / HUD_LINES + h.fontGame = fontLoad(font, points * 4) + h.fontLCD = fontLoad(font, points * 5) + h.fontBig = fontLoad(font, points * 6) + for name, file in pairs(HUD_SPRITES) do + h.sprite[name] = hudSprite(h, file) + end + for digit = 0, 9 do + h.number[digit] = hudSprite(h, string.format("num%02d.png", digit)) + end + h.prompt = {} + for kind, file in pairs(HUD_PROMPTS) do + h.prompt[kind] = hudSprite(h, file) + end + fontSelect(h.fontGame) + zero = fontToSprite("0") + h.RENDH = spriteGetHeight(zero) + spriteUnload(zero) + fontSelect(AUTHOR_FONT) + h.NUMW = h.number[0] and spriteGetWidth(h.number[0]) or 0 + h.NUMH = h.number[0] and spriteGetHeight(h.number[0]) or 0 + h.LIVW = h.sprite.lives and spriteGetWidth(h.sprite.lives) or 0 + h.LIVH = h.sprite.lives and spriteGetHeight(h.sprite.lives) or 0 + h.BARW = h.sprite.barFull and spriteGetWidth(h.sprite.barFull) or 0 + h.BARH = h.sprite.barFull and spriteGetHeight(h.sprite.barFull) or 0 + h.SKIPW = h.sprite.skip and spriteGetWidth(h.sprite.skip) or 0 + h.TEXTH = h.sprite.skip and spriteGetHeight(h.sprite.skip) or 0 + h.CRDW = h.sprite.credit and spriteGetWidth(h.sprite.credit) or 0 + h.SCNW = h.sprite.scenes and spriteGetWidth(h.sprite.scenes) or 0 + h.SELCW = h.sprite.select and spriteGetWidth(h.sprite.select) or 0 + h.TEXW = h.sprite.stick and spriteGetWidth(h.sprite.stick) or 0 + h.BUTW = h.prompt[5] and spriteGetWidth(h.prompt[5]) or 0 + h.BUTH = h.prompt[5] and spriteGetHeight(h.prompt[5]) or 0 + h.ARUW = h.prompt[1] and spriteGetWidth(h.prompt[1]) or 0 + h.ARUH = h.prompt[1] and spriteGetHeight(h.prompt[1]) or 0 + h.ARLW = h.prompt[3] and spriteGetWidth(h.prompt[3]) or 0 + h.ARLH = h.prompt[3] and spriteGetHeight(h.prompt[3]) or 0 + h.posxUI = (h.OVLW / 2) - (h.SKIPW / 2) + h.posxTEXT = (h.OVLW / 2) - (h.TEXW / 2) + h.posxBUTTONS = (h.OVLW / 2) - (h.BUTW / 2) + h.posyBUTTONS = (h.OVLH / 2) - (h.BUTH / 2) + h.posxUDARROWS = (h.OVLW / 2) - (h.ARUW / 2) + h.posyLRARROWS = (h.OVLH / 2) - (h.ARLH / 2) + -- The arrows slide in from 28 lines in to 8 lines from the edge, 32 and 12 across. + h.startyUARROW = h.LINEH * 28 + h.startyDARROW = h.OVLH - h.ARUH - h.LINEH * 28 + h.endyUARROW = h.LINEH * 8 + h.endyDARROW = h.OVLH - h.ARUH - h.LINEH * 8 + h.startxLARROW = h.LINEW * 32 + h.startxRARROW = h.OVLW - h.ARLW - h.LINEW * 32 + h.endxLARROW = h.LINEW * 12 + h.endxRARROW = h.OVLW - h.ARLW - h.LINEW * 12 + h.ycoinpos = (q.CoinPos == 1) and (h.OVLH / 2 - h.TEXTH / 2) or (h.OVLH - h.TEXTH - h.LINEH) + h.DISKW = h.sprite.disk1 and spriteGetWidth(h.sprite.disk1) or 0 + h.DISKH = h.sprite.disk1 and spriteGetHeight(h.sprite.disk1) or 0 + h.ydskpos = (q.LivPos == 1) and (h.OVLH - h.DISKH - h.LINEH) or h.LINEH + -- Lives at the top or the bottom (LivPos), the score and the level name opposite (ScorePos). + if q.LivPos == 1 then + h.ylivpos = h.LINEH + elseif q.dip_GameType == 1 then + h.ylivpos = h.OVLH - h.BARH - h.LINEH + else + h.ylivpos = h.OVLH - h.LIVH - h.LINEH + end + if q.ScorePos == 1 then + h.yscopos = h.LINEH + h.ylvlpos = h.OVLH - h.RENDH - h.LINEH + else + h.yscopos = h.OVLH - h.NUMH - h.LINEH + h.ylvlpos = h.LINEH + end + q.hud = h +end + + +-- A number in the game's digit pictures, or in its font when the dips say so (dip_ScType). +function Q.hudNumber(q, x, y, amount) + local h = q.hud + local text = tostring(math.floor(amount)) + + if (q.dip_ScType or 0) == 0 then + for k = 1, #text do + local digit = h.number[tonumber(text:sub(k, k))] + + if digit then + spriteDraw(digit, x + (k - 1) * h.NUMW, y) + end + end + else + fontSelect(h.fontGame) + fontPrint(x, y, text) + fontSelect(AUTHOR_FONT) + end +end + + +-- The score, and the record over the middle when the loop shows it (or the game is paused). +function Q.hudScore(q) + local h = q.hud + + Q.hudNumber(q, h.LINEW, h.yscopos, q.iScore or 0) + if (q.bShowTop or q.bPause) and h.sprite.topScore then + spriteDraw(h.sprite.topScore, h.posxUI, h.LINEH) + if q.dip_GameType == 3 then + Q.hudNumber(q, (h.OVLW / 2) - h.NUMW, h.LINEH * 3, q.iTop or 0) + else + Q.hudNumber(q, (h.OVLW / 2) - h.NUMW * 3, h.LINEH * 3, q.iTop or 0) + end + end +end + + +-- The lives right to left from the right edge, or the life bar in the life-bar game type. +function Q.hudLives(q) + local h = q.hud + + if q.dip_GameType == 1 then + local size = q.BarSize or 0 + local left = h.OVLW - (h.LINEW * (2 + (q.dip_Res or 0))) - (h.BARW * (size + 1)) + + if (q.iLifeBar or 0) > 0 then + for k = 1, q.iLifeBar do + spriteDraw(h.sprite.barFull, left + (h.BARW + 2) * k, h.ylivpos) + end + if q.iLifeBar < size then + for k = q.iLifeBar, size do + spriteDraw(h.sprite.barNone, left + (h.BARW + 2) * k, h.ylivpos) + end + end + end + elseif (q.iLives or 0) > 0 then + if (q.dip_LivType or 0) == 0 then + for k = 1, q.iLives do + spriteDraw(h.sprite.lives, h.OVLW - h.LINEW - h.LIVW * k, h.ylivpos) + end + else + spriteDraw(h.sprite.lives, h.OVLW - 2 * h.LINEW - h.LIVW * 2, h.ylivpos) + fontSelect(h.fontGame) + fontPrint(h.OVLW - h.LINEW - h.LIVW, h.ylivpos + h.LIVH / 4, "x" .. q.iLives) + fontSelect(AUTHOR_FONT) + end + end +end + + +-- Whether a blinking picture is on this frame, counted from when it appeared, on first: the +-- engine's clock, where the games blink on the CPU's, so a blink's phase can drift from an +-- original's. Q.hudDraw forgets when a picture went away, so its next showing starts on. +local function hudBlinkOn(h, name, seconds) + local now = singeGetTicks() + + h.shown[name] = h.shown[name] or now + + return math.floor((now - h.shown[name]) / (seconds * 1000)) % 2 == 0 +end + + +-- Text centred across the overlay, in the font selected. +local function hudMiddle(h, text) + local sprite = fontToSprite(text) + local x = h.OVLW / 2 - spriteGetWidth(sprite) / 2 + + spriteUnload(sprite) + + return x +end + + +-- The level's title on its first scene (numbered but for the secret level), then the scene's +-- number, while the level shows (ShowLevel; the loop turns bShowLvl off past the first move). +function Q.hudLevel(q) + local h = q.hud + local level = q.Level[q.iLevel] + + if (not q.ShowLevel) or (level == nil) then + return + end + fontSelect(h.fontGame) + if q.iScene == 1 then + fontPrint(h.LINEW, h.ylvlpos, (q.iLevel == q.levelSecret) and level[QTE.TITLE] or (q.iLevel .. ". " .. level[QTE.TITLE])) + elseif h.sprite.scenes then + spriteDraw(h.sprite.scenes, h.LINEW, h.ylvlpos) + Q.hudNumber(q, h.LINEW + h.SCNW + h.NUMW, h.ylvlpos, q.iScene or 0) + end + fontSelect(AUTHOR_FONT) +end + + +-- FREE PLAY, the credits, or INSERT COIN, blinking. +function Q.hudCredits(q) + local h = q.hud + + if not hudBlinkOn(h, "credits", HUD_BLINK) then + return + end + if q.dip_CoinsPerCredit == q.DOPT_FREEPLAY then + spriteDraw(h.sprite.freePlay, h.posxUI, h.ycoinpos) + elseif (q.iCredits or 0) > 0 then + spriteDraw(h.sprite.credit, (h.OVLW / 2) - h.CRDW / 2 - h.NUMW, h.ycoinpos) + Q.hudNumber(q, (h.OVLW / 2) - h.CRDW / 2 + h.CRDW, h.ycoinpos, q.iCredits) + else + spriteDraw(h.sprite.insertCoin, h.posxUI, h.ycoinpos) + end +end + + +-- GET READY, blinking, when the game has the picture. +function Q.hudGetReady(q) + local h = q.hud + + if h.sprite.getReady and hudBlinkOn(h, "getReady", HUD_BLINK) then + spriteDraw(h.sprite.getReady, h.posxTEXT, h.posyBUTTONS) + end +end + + +-- The skip prompt over a clip a button skips, blinking. +function Q.hudSkip(q) + local h = q.hud + + if h.sprite.skip and hudBlinkOn(h, "skip", HUD_BLINK + 0.1) then + spriteDraw(h.sprite.skip, h.posxUI, h.OVLH - h.TEXTH - h.LINEH) + end +end + + +-- A choice move's menu: TIMESTOP, SELECT YOUR ACTION, the options in the loop's shuffled order, +-- and the selector at the one chosen. +function Q.hudChoose(q) + local h = q.hud + local m = q.move[q.currentMove] + local count = m and m[QTE.moveDeath] or 0 + local x = h.OVLW / 2.5 + + if (q.optorder == nil) or (count < 2) then + return + end + fontSelect(h.fontLCD) + Q.fontColour(q, q.mycolor2) + fontPrint(hudMiddle(h, "TIMESTOP"), h.OVLH / 5, "TIMESTOP") + Q.fontColour(q, q.mycolor1) + fontPrint(hudMiddle(h, "SELECT YOUR ACTION"), h.OVLH / 5 + h.LINEH * 3, "SELECT YOUR ACTION") + Q.fontColour(q, q.mycolor2) + for row = 1, math.min(count, 4) do + local option = q.choice[q.optorder[row]] + local y = h.OVLH / 3 + h.LINEH * 6 * (row - 1) + + if option then + fontPrint(x, y, option[1]) + end + if (q.iChoice == row) and h.sprite.select then + spriteDraw(h.sprite.select, x - h.SELCW - 10, y + 5) + end + end + fontSelect(AUTHOR_FONT) +end + + +-- PAUSED, large, over the middle. +function Q.hudPause(q) + local h = q.hud + + fontSelect(h.fontBig) + fontPrint(hudMiddle(h, "PAUSED"), h.OVLH / 2 - 3 * h.LINEH, "PAUSED") + fontSelect(AUTHOR_FONT) +end + + +-- The prompt for the move waited on, by the prompt setting (dip_ShowAction): none, a sound only +-- (the loop plays it), the nomoves picture, the kind of input (stick, buttons, both, specials), +-- or the input itself (4, and 5 with the sound too) -- a direction's arrow sliding in from the +-- middle, a button's picture. The compound kinds (mashes, holds, runs, the gauge, timed moves) +-- are not drawn yet: their pictures differ between the framework's versions. +function Q.hudAction(q) + local h = q.hud + local m = q.move[q.currentMove] + local kind = m and m[QTE.correctMove] + local show = q.dip_ShowAction or 0 + + if (kind == nil) or ((q.currentMove or 0) > (q.totalMoves or 0)) then + return + end + -- A move's arrows start over from the middle each time its prompt shows (a new move, or the + -- same one again after a death), and slide out a line a frame. + if h.actionMove ~= q.currentMove then + h.actionMove = q.currentMove + h.posyUARROW, h.posyDARROW = h.startyUARROW, h.startyDARROW + h.posxLARROW, h.posxRARROW = h.startxLARROW, h.startxRARROW + end + if ((show == 0) and (kind ~= q.SKIP)) or ((show == 1) and (kind < q.CHOOSE)) then + return + elseif (show == 2) and (kind < q.CHOOSE) then + spriteDraw(h.sprite.noMoves, h.posxTEXT, h.posyBUTTONS) + elseif (show == 3) and (kind < q.CHOOSE) then + if ((kind >= 1) and (kind <= 4)) or ((kind >= 9) and (kind <= 12)) then + spriteDraw(h.sprite.stick, h.posxTEXT, h.posyBUTTONS) + elseif (kind >= 5) and (kind <= 8) then + spriteDraw(h.sprite.buttons, h.posxTEXT, h.posyBUTTONS) + elseif (kind >= 13) and (kind <= 16) then + spriteDraw(h.sprite.buttons, h.posxTEXT, h.posyBUTTONS + h.BUTH / 2) + spriteDraw(h.sprite.stick, h.posxTEXT, h.posyBUTTONS - h.BUTH / 2) + elseif kind >= 17 then + spriteDraw(h.sprite.specials, h.posxTEXT, h.posyBUTTONS + h.LINEH) + end + elseif (kind >= q.UP) and (kind <= q.RIGHT) then + if kind == q.UP then + h.posyUARROW = math.max(h.endyUARROW, h.posyUARROW - h.LINEH) + spriteDraw(h.prompt[kind], h.posxUDARROWS, h.posyUARROW) + elseif kind == q.DOWN then + h.posyDARROW = math.min(h.endyDARROW, h.posyDARROW + h.LINEH) + spriteDraw(h.prompt[kind], h.posxUDARROWS, h.posyDARROW) + elseif kind == q.LEFT then + h.posxLARROW = math.max(h.endxLARROW, h.posxLARROW - h.LINEW) + spriteDraw(h.prompt[kind], h.posxLARROW, h.posyLRARROWS) + else + h.posxRARROW = math.min(h.endxRARROW, h.posxRARROW + h.LINEW) + spriteDraw(h.prompt[kind], h.posxRARROW, h.posyLRARROWS) + end + elseif (kind == q.CHOOSE) or (kind == q.YESNO) then + -- A choice is the button; a yes or no, the button over "no moves". + spriteDraw(h.prompt[q.BUTTON1], h.posxBUTTONS, h.posyBUTTONS) + if kind == q.YESNO then + spriteDraw(h.sprite.noMoves, h.posxTEXT, h.posyBUTTONS + h.LINEH + h.BUTH) + end + elseif kind == q.PATH then + spriteDraw(h.sprite.noMoves, h.posxTEXT, h.posyBUTTONS) + elseif h.prompt[kind] then + spriteDraw(h.prompt[kind], h.posxBUTTONS, h.posyBUTTONS) + end +end + + +-- A board's rows under its mode title: the rank, the name, and the score at fractions of the +-- overlay's width (the rank's shifts for "1." and "10."), the first row in the second colour. +local BOARD_COLUMNS = { rank = { 2.56, 2.64, 2.73 }, name = 2.37, score = 1.92 } +-- The three boards of the attract loop's rankings side by side, at their own fractions. +local BOARDS_ALL = { + { board = "lifeBar", rank = { 21.8, 29.4, 43.64 }, name = 13.33, score = 5.69 }, + { board = "normal", rank = { 2.56, 2.64, 2.73 }, name = 2.37, score = 1.92 }, + { board = "survivor", rank = { 1.34, 1.36, 1.37 }, name = 1.28, score = 1.13 } +} +local BOARD_TITLES = { [1] = "LIFE BAR MODE", [3] = "SURVIVOR" } + + +-- Ten rows of a board at the columns given, from the row the games start a board on. +local function hudBoardRows(q, board, columns) + local h = q.hud + + for k = 1, 10 do + local entry = board[k] + local rank = (k == 1) and columns.rank[1] or ((k == 10) and columns.rank[3] or columns.rank[2]) + local y = h.OVLH / 4.59 + k * h.LINEH * 5 + + Q.fontColour(q, (k == 1) and q.mycolor2 or q.mycolor1) + fontPrint(h.OVLW / rank, y, k .. ".") + if entry then + fontPrint(h.OVLW / columns.name, y, tostring(entry[1])) + fontPrint(h.OVLW / columns.score, y, tostring(entry[2])) + end + end +end + + +-- RANKINGS over a board, when the game shows its titles (ShowTitle). +local function hudRankingsTitle(q) + local h = q.hud + + if q.ShowTitle then + fontSelect(h.fontBig) + Q.fontColour(q, q.mycolor2) + fontPrint(hudMiddle(h, "RANKINGS"), h.LINEH * 4, "RANKINGS") + end +end + + +-- The game type's board after a result went onto it: its mode's title and its ten rows. +function Q.hudBoard(q) + local h = q.hud + local title = BOARD_TITLES[q.dip_GameType] or "NORMAL MODE" + + hudRankingsTitle(q) + fontSelect(h.fontLCD) + Q.fontColour(q, q.mycolor2) + fontPrint(hudMiddle(h, title), h.LINEH * 11, title) + hudBoardRows(q, Q.boardFor(q), BOARD_COLUMNS) +end + + +-- The attract loop's rankings: the three boards side by side under their headings (their places +-- as the games set them, wider screens a little further out). +function Q.hudBoardsAll(q) + local h = q.hud + local wide = h.OVLW / h.OVLH > 1.7 + + hudRankingsTitle(q) + fontSelect((h.OVLW >= 1920) and h.fontLCD or h.fontGame) + Q.fontColour(q, q.mycolor2) + fontPrint(hudMiddle(h, "NORMAL"), h.LINEH * 15, "NORMAL") + fontPrint(h.OVLW / (wide and 10.58 or 11.9), h.LINEH * 15, "LIFE BAR") + fontPrint(h.OVLW / (wide and 1.28 or 1.34), h.LINEH * 15, "SURVIVOR") + for _, columns in ipairs(BOARDS_ALL) do + hudBoardRows(q, q.boards[columns.board], columns) + end +end + + +-- The name being spelled, the letter grid with its cursor blinking, and the titles. +function Q.hudNameEntry(q) + local h = q.hud + local spelled = "" + + if q.ShowTitle then + Q.fontColour(q, q.mycolor2) + fontSelect(h.fontBig) + fontPrint(hudMiddle(h, "HIGH SCORE!!"), h.LINEH * 4, "HIGH SCORE!!") + fontSelect(h.fontGame) + fontPrint(hudMiddle(h, "PLEASE ENTER YOUR NAME"), h.OVLH - h.LINEH * 4, "PLEASE ENTER YOUR NAME") + end + for k = 1, 3 do + spelled = spelled .. ((k <= #q.sName) and q.sName:sub(k, k) or "_") .. " " + end + fontSelect(h.fontLCD) + Q.fontColour(q, q.mycolor2) + fontPrint(hudMiddle(h, spelled), h.OVLH / 4, spelled) + for k = 1, NAME_END do + local column = (k - 1) % NAME_ROW + local x = (h.OVLW - 33 * h.LINEW) / 2 + column * h.LINEW * 4 + local y = h.OVLH / 3 + math.floor((k - 1) / NAME_ROW) * h.LINEH * 5 + + Q.fontColour(q, ((k == q.iCursor) and hudBlinkOn(h, "cursor", HUD_BLINK)) and q.mycolor2 or q.mycolor1) + if k < NAME_CLEAR then + fontPrint(x, y, NAME_LETTERS:sub(k, k)) + elseif k == NAME_CLEAR then + fontPrint(x - h.LINEW, y, "CLR") + else + fontPrint(x + h.LINEW * 2, y, "END") + end + end +end + + +-- The attract loop's best percent of each level, three levels a row, each row centred as the +-- games centre it (on a row of three of the widest they allow for, whatever it holds). +function Q.hudPercent(q) + local h = q.hud + local levels = #q.boards.percent + local step = (levels > 11) and 3 or 5.5 + local widest = { "L2: BAB 99 (E)", "L2: BAB 99 (E) L2: BAB 99 (E)", "L2: BAB 99 (E) L2: BAB 99 (E) L2: BAB 99 (E)" } + + hudRankingsTitle(q) + if levels > 11 then + fontSelect((h.OVLW >= 1920) and h.fontLCD or h.fontGame) + else + fontSelect((h.OVLW >= 1920) and h.fontBig or h.fontGame) + end + Q.fontColour(q, q.mycolor2) + fontPrint(hudMiddle(h, "BEST PERCENT"), h.LINEH * 11, "BEST PERCENT") + Q.fontColour(q, q.mycolor1) + for first = 1, levels, 3 do + local cells = {} + + for k = first, math.min(first + 2, levels) do + local best = q.boards.percent[k] + + cells[#cells + 1] = "L" .. k .. ": " .. best[1] .. " " .. best[2] .. " (" .. best[3] .. ")" + end + fontPrint(hudMiddle(h, widest[#cells]), h.OVLH / 5 + first * h.LINEH * step, table.concat(cells, " ")) + end +end + + +-- The value a row of the options screen shows, "N/A" where the game type or play style rules +-- the option out, as the games print them. +local function serviceValue(q, row) + local gameType = q.dip_GameType + local style = q.dip_PlayStyle + + if row == 2 then + return SERVICE_GAME_TYPES[gameType] + elseif row == 3 then + return (gameType == 5) and "N/A" or SERVICE_DIFFICULTY[q.dip_Diffshow] + elseif row == 4 then + return ((gameType == 2) or (gameType == 3) or (gameType == 5)) and "N/A" or SERVICE_STYLES[style] + elseif row == 5 then + return ((style > 0) or (gameType == 3) or (gameType == 5)) and "N/A" or (q.dip_StartLevel .. ". " .. q.Level[q.dip_StartLevel][QTE.TITLE]) + elseif row == 6 then + return ((gameType == 2) or (gameType == 3) or (gameType == 5) or (style > 0)) and "N/A" or tostring(q.dip_StartScene) + elseif row == 7 then + return ((gameType == 2) or (gameType == 3) or (gameType == 5)) and "N/A" or SERVICE_REWINDS[q.dip_Rewind] + elseif row == 8 then + return (q.dip_CoinsPerCredit == q.DOPT_FREEPLAY) and "Free Play" or tostring(q.dip_CoinsPerCredit) + elseif row == 9 then + return ((gameType == 1) or (gameType == 2) or (gameType == 5)) and "N/A" or ((gameType == 3) and "1" or tostring(q.dip_LivesPerCredit)) + elseif row == 10 then + if (gameType == 2) or (gameType == 5) then + return "N/A" + elseif (gameType == 3) or (q.dip_LimitContinue == 0) then + return "None" + end + return (q.dip_LimitContinue == 4) and "Unlimited" or tostring(q.dip_LimitContinue) + elseif row == 11 then + return (gameType == 5) and "N/A" or SERVICE_PROMPTS[q.dip_ShowAction] + elseif row == 12 then + return ((gameType == 1) or (gameType == 2) or (gameType == 3) or (gameType == 5)) and "N/A" or (q.dip_Hints and "Yes" or "No") + elseif row == 13 then + return ((gameType == 4) or (gameType == 5)) and "N/A" or SERVICE_MASHES[q.dip_MashtoRun] + end + + return nil +end + + +local SERVICE_EXTRA_NAMES = { [0] = "None", "Ready", "Death", "Clear", "Ready/Death", "Ready/Clear", "Clear/Death", "All" } +local SERVICE_COLOURS = { "WHITE", "RED", "BLUE", "YELLOW", "GREEN", "ORANGE", "PINK", "PURPLE", "LIGHTBLUE", "GREY", "BLACK", "MISC" } + + +-- The value a row of the graphics screen shows. +local function serviceValueGraphics(q, row) + if row == 2 then + return (q.dip_GameType == 5) and "N/A" or SERVICE_EXTRA_NAMES[q.dip_Extravid or 0] + elseif (row >= 3) and (row <= 6) then + return q[({ "ShowTitle", "ShowLCD", "ShowLevel", "ShowTop" })[row - 2]] and "Yes" or "No" + elseif row == 7 then + return (q.ScorePos == 1) and "TOP" or "BOTTOM" + elseif row == 8 then + return (q.LivPos == 1) and "TOP" or "BOTTOM" + elseif row == 9 then + return (q.CoinPos == 1) and "MIDDLE" or "BOTTOM" + elseif row == 10 then + return (q.fontType == 1) and "GAME" or "DEFAULT" + elseif row == 11 then + return tostring(q.fontSize) + elseif row == 12 then + return SERVICE_COLOURS[q.mycolor1] + elseif row == 13 then + return SERVICE_COLOURS[q.mycolor2] + end + + return nil +end + + +-- The value a row of the performance screen shows. +local function serviceValuePerformance(q, row) + if row == 2 then + return ({ [0] = "Full", "Medium", "Low", "Lowest" })[q.dip_Model or 0] + elseif row == 3 then + return (q.dip_Res == 0) and "Hi" or "Lo" + elseif row == 4 then + return (q.dip_Display == 0) and "Full" or "Basic" + elseif row == 5 then + return q.bScan and ({ [0] = "No", "Over text", "Under text" })[q.dip_Scanlines or 0] or "N/A" + elseif (row == 6) or (row == 7) then + return ((row == 6) and q.dip_ScType or q.dip_LivType) == 0 and "PNG" or "Rendered" + elseif row == 8 then + return ({ "Solid", "Shaded", "Blended" })[q.dip_FontQ] + end + + return nil +end + + +-- The save screen's rows: load or save, the slot (the autosave loads only), PROCEED, and EXIT. A +-- save made here keeps the lives the level began with, and the game type as the life bar or not, +-- and is made only when the score has not fallen since the level began (as the games save). +function Q.serviceChangeSave(q, row, step) + if row == 1 then + q.dip_Loadsave = serviceStep(q.dip_Loadsave or 0, step, 0, 1) + if q.dip_Loadsave == 1 then + q.dip_Slot = 1 + end + elseif row == 2 then + -- Stepping on to the autosave turns saving off; stepping back round to it does not. + q.dip_Slot = (q.dip_Slot or 1) + step + if q.dip_Slot == 4 then + q.dip_Loadsave = 0 + end + q.dip_Slot = serviceStep(q.dip_Slot, 0, 1, 4) + elseif row == 3 then + if (q.dip_Loadsave or 0) == 0 then + Q.loadSave(q, q.dip_Slot or 1) + else + if q.iScore >= (q.iScoreSave or 0) then + Q.saveWrite(q, q.dip_Slot or 1, { q.iLevel, q.iScene, q.bCheckMove and (q.currentMove + 1) or q.currentMove, (q.dip_GameType == 1) and q.iLifeBar or q.iLiveSave, q.dip_Difficulty, q.iScore, (q.dip_GameType == 1) and 1 or 0, q.iScPlayed, q.iScDeath, q.iTotDeath, q.dip_PlayStyle }) + end + q.lvlState = QTE.lvlEnd + end + elseif row == 4 then + q.lvlState = QTE.lvlEnd + end +end + + +-- The save screen's own start: its still, the timer and the HUD off. +local function saveScreenSetup(q) + discSkipToFrame(q.frameSave) + discPause() + q.timerLimit = nil + q.bShowScore, q.bShowLives, q.bShowLvl, q.bShowScene, q.bShowSkip = false, false, false, false, false + q.bShowCredits, q.bShowLCD, q.bShowGet, q.bShowAction, q.bIgnoreJoy = false, false, false, false, false +end + + +-- The exit screen's rows: QUIT ends Singe, BACK goes back to the game, INTRO to the attract loop, +-- OPTIONS to the options screen. +function Q.serviceChangeExit(q, row) + if row == 1 then + singeQuit() + elseif row == 2 then + q.lvlState = QTE.lvlEnd + elseif row == 3 then + q.bInPlayExit = false + q.lvlState = QTE.lvlEnd + else + q.bInPlayExit = false + q.lvlState = QTE.lvlSetup + q.currentLevel = QTE.levelService + end +end + + +-- The exit screen's own start: the hints still, the timer and the HUD off. +local function exitScreenSetup(q) + discSkipToFrame(q.frameHints) + discPause() + q.timerLimit = nil + q.bShowLvl, q.bShowScene, q.bShowScore, q.bShowLives, q.bShowSkip = false, false, false, false, false + q.bShowCredits, q.bShowGet, q.bShowLCD, q.bShowAction, q.bIgnoreJoy = false, false, false, false, false +end + + +-- The service screens, as the shared loop and printer read them: the state and row they keep, the +-- title row between arrows, the labels of the rows between it and the last two, how a row shows +-- its value, and how it changes; the save and exit screens have rows of their own and draw +-- themselves. +local SERVICE_SCREENS = { + [QTE.levelService] = { state = "lvlState", row = "optSel", setup = serviceSetup, labels = SERVICE_LABELS, value = serviceValue, change = function(q, row, step) Q.serviceChange(q, row, step) end }, + [QTE.levelGraphic] = { state = "altState", row = "optSelG", labels = { "Graphic Options", "Extra Movies", "Show Titles", "Show LCD", "Show Level", "Show Top Score", "Score Position", "Lives Position", "Coin Position", "Font", "Adjust Font Size", "Font Color", "Highlight Color" }, value = serviceValueGraphics, change = function(q, row, step) Q.serviceChangeGraphics(q, row, step) end }, + [QTE.levelPerf] = { state = "altState2", row = "optSelP", labels = { "Performances", "Configurations", "Overlay Res", "Display Scoring", "Scanlines", "Score Type", "Lives Type", "Font Quality" }, value = serviceValuePerformance, change = function(q, row, step) Q.serviceChangePerformance(q, row, step) end }, + [QTE.levelSave] = { state = "lvlState", row = "optSel", rows = 4, setup = saveScreenSetup, leave = serviceLeaveToGame, change = function(q, row, step) Q.serviceChangeSave(q, row, step) end, draw = function(q) Q.hudSave(q) end }, + [QTE.levelExit] = { state = "lvlState", row = "optSel", rows = 4, buttonOnly = true, setup = exitScreenSetup, leave = serviceLeaveToGame, change = function(q, row) Q.serviceChangeExit(q, row) end, draw = function(q) Q.hudExit(q) end } +} + + +-- A screen of options: its title, the rows with the one chosen in the second colour, the first +-- between arrows, the last two centred low (at the same height whatever the screen's length). +function Q.hudService(q, screen) + local h = q.hud + local rows = #screen.labels + 2 + local last = q.bInPlayExit and { "Restart", "BACK TO GAME" } or { "Default", "EXIT" } + + if q.ShowTitle then + Q.fontColour(q, q.mycolor2) + fontSelect(h.fontBig) + fontPrint(hudMiddle(h, "OPTIONS MENU"), h.LINEH * 2, "OPTIONS MENU") + end + fontSelect(h.fontGame) + for row = 1, rows do + local y = h.OVLH / 7.5 + (row - 1) * h.LINEH * 4.2 + local value = screen.value(q, row) + + Q.fontColour(q, (row == q[screen.row]) and q.mycolor2 or q.mycolor1) + if row == 1 then + spriteDraw(h.sprite.selectLeft, h.OVLW / 3 - h.SELCW, y) + spriteDraw(h.sprite.select, 2 * h.OVLW / 3, y) + fontPrint(hudMiddle(h, screen.labels[1]), y, screen.labels[1]) + elseif row >= rows - 1 then + local label = last[row - rows + 2] + + fontPrint(hudMiddle(h, label), h.OVLH / 8 + (row - rows + 14.5) * h.LINEH * 4.2, label) + else + fontPrint(h.OVLW / 4.8, y, screen.labels[row]) + end + if value then + fontPrint(h.OVLW / 1.6, y, value) + end + end +end + + +-- A slot as the save screen lists it: empty, or the level, the scene (or the map), the move, the +-- lives (or the bar), the difficulty, and the score; the fourth is the autosave, "A". +local function saveLine(q, k) + local save = q.saves[k] + local line = save and save.line or { 1, 1, 1 } + local label = (k < 4) and tostring(k) or "A" + local scene = tonumber(line[2]) or 0 + + if (tonumber(line[1]) == 1) and ((scene == 1) or (scene == 0)) and (tonumber(line[3]) == 1) then + return label .. ". Empty" + end + + return label .. ". Lvl:" .. line[1] .. ((scene <= 0) and ", Map" or (", Scn:" .. line[2])) .. ", Mov:" .. line[3] .. ((tonumber(line[7]) == 5) and ", Bar:" or ", Liv:") .. line[4] .. ", " .. (SERVICE_DIFFICULTY[tonumber(line[5])] or "") .. ", " .. line[6] +end + + +-- The save screen: its title, the four slots, and LOAD or SAVE, the slot, PROCEED, and EXIT (or +-- BACK TO GAME) with the one chosen in the second colour. +function Q.hudSave(q) + local h = q.hud + local slots = { "SLOT #1", "SLOT #2", "SLOT #3", "AUTOSAVE" } + local rows = { ((q.dip_Loadsave or 0) == 0) and "LOAD" or "SAVE", slots[q.dip_Slot or 1], "PROCEED", q.bInPlayExit and "BACK TO GAME" or "EXIT" } + + if q.ShowTitle then + Q.fontColour(q, q.mycolor2) + fontSelect(h.fontBig) + fontPrint(hudMiddle(h, "LOAD/SAVE MENU"), h.LINEH * 2, "LOAD/SAVE MENU") + end + Q.fontColour(q, q.mycolor2) + fontSelect(h.fontGame) + for k = 1, 4 do + fontPrint(h.OVLW / 6.55, h.OVLH / 5 + k * h.LINEH * 5, saveLine(q, k)) + end + fontSelect(h.fontLCD) + for k = 1, 4 do + Q.fontColour(q, (k == q.optSel) and q.mycolor2 or q.mycolor1) + fontPrint(hudMiddle(h, rows[k]), h.OVLH / 5 + (k + 6) * h.LINEH * 5, rows[k]) + end +end + + +-- The exit screen: QUIT GAME? over QUIT, BACK, INTRO, and OPTIONS, the one chosen in the second +-- colour. +function Q.hudExit(q) + local h = q.hud + local rows = { "QUIT", "BACK", "INTRO", "OPTIONS" } + + Q.fontColour(q, q.mycolor2) + fontSelect(h.fontBig) + fontPrint(hudMiddle(h, "QUIT GAME?"), h.LINEH * 2, "QUIT GAME?") + for k = 1, 4 do + Q.fontColour(q, (k == q.optSel) and q.mycolor2 or q.mycolor1) + fontPrint(hudMiddle(h, rows[k]), h.OVLH / 1.8 + k * h.LINEH * 7, rows[k]) + end +end + + +-- What a screen draws of its own, before the HUD: the name's grid and the board of the high +-- score screen, the three boards of the attract loop's rankings. +function Q.hudScreen(q) + if q.currentLevel == QTE.levelHighScore then + if (q.lvlState == QTE.lvlRunning) and Q.option(q, "nameEntry", true) then + Q.hudNameEntry(q) + elseif q.lvlState == QTE.branch02 then + Q.hudBoard(q) + end + elseif (q.currentLevel == QTE.levelIntro) and (q.lvlState == QTE.branch05) then + Q.hudBoardsAll(q) + elseif (q.currentLevel == QTE.levelIntro) and (q.lvlState == QTE.branch10) then + Q.hudPercent(q) + elseif SERVICE_SCREENS[q.currentLevel] and (q[SERVICE_SCREENS[q.currentLevel].state] == QTE.lvlRunning) then + local screen = SERVICE_SCREENS[q.currentLevel] + + if screen.draw then + screen.draw(q) + else + Q.hudService(q, screen) + end + end + fontSelect(AUTHOR_FONT) +end + + +-- The HUD, in the games' order, over whatever the screen itself drew; none while the title clip +-- plays and the game starts, as the games draw theirs only once it runs. +function Q.hudDraw(q) + if q.gameflow ~= "running" then + return + end + if q.hud == nil then + Q.hudLoad(q) + end + local h = q.hud + + -- Scanlines under the text (dip_Scanlines 2) or over it (1). + if (q.dip_Scanlines == 2) and h.sprite.scanlines then + spriteDraw(h.sprite.scanlines, 0, 0) + end + Q.hudScreen(q) + if q.bShowAction then + Q.hudAction(q) + else + q.hud.actionMove = nil + end + if q.bShowScore then + Q.hudScore(q) + end + if q.bShowLives then + Q.hudLives(q) + end + if q.bShowCredits then + Q.hudCredits(q) + else + q.hud.shown.credits = nil + end + if q.bShowSkip then + Q.hudSkip(q) + else + q.hud.shown.skip = nil + end + if q.diskVisible and h.sprite["disk" .. q.diskVisible] then + spriteDraw(h.sprite["disk" .. q.diskVisible], h.OVLW - h.DISKW - h.LINEW, h.ydskpos) + end + if q.bShowChoices then + Q.hudChoose(q) + end + if q.bPause then + Q.hudPause(q) + end + if q.bShowGet then + Q.hudGetReady(q) + else + q.hud.shown.getReady = nil + end + if q.bShowLvl then + Q.hudLevel(q) + end + if (q.dip_Scanlines == 1) and h.sprite.scanlines then + spriteDraw(h.sprite.scanlines, 0, 0) + end + colorForeground(255, 255, 255) +end + + +-- The games' font colours (their toolbox's setFontColor), for the loops that do not -- load that file; a game that defines its own keeps it. local FONT_COLOURS = { { 255, 255, 255 }, { 255, 0, 0 }, { 0, 0, 255 }, { 255, 255, 0 }, { 0, 255, 0 }, { 255, 150, 0 }, { 252, 0, 148 }, { 127, 0, 255 }, { 30, 160, 250 }, { 128, 128, 128 }, { 1, 0, 0 } } @@ -7506,7 +9664,7 @@ function Q.configLoad(q) q[name] = saved[name] end end - -- The board the game keeps in the same file as its dips (the American Laser Games editions). + -- The board the game keeps in the same file as its settings (the gun games). if (type(saved.highscore) == "table") and (type(q.highscore) == "table") then q.highscore = saved.highscore end @@ -7576,9 +9734,19 @@ function Q.shim(q) end end end, - spriteDrawFrame = function(x, y, frame, handle) - if isHandle(handle) and (type(frame) == "number") then - Q.queueDraw(q, function() spriteDrawFrame(handle, x, y, math.max(1, math.floor(frame))) end) + -- Every old form of it, as the engine's Framework.singe takes them: (x, y, frame, sprite), + -- (x, y, scale, frame, sprite), or (x, y, scaleX, scaleY, frame, sprite). The sprite is last, + -- the frame before it, and whatever sits between the position and the frame is the scale. + spriteDrawFrame = function(...) + local args = { ... } + local handle = table.remove(args) + local frame = table.remove(args) + local x = args[1] + local y = args[2] + local scale = { table.unpack(args, 3) } + + if isHandle(handle) and (type(frame) == "number") and (type(x) == "number") and (type(y) == "number") then + Q.queueDraw(q, function() spriteDrawFrame(handle, x, y, math.max(1, math.floor(frame)), table.unpack(scale)) end) end end, fontPrint = function(x, y, text) @@ -7598,7 +9766,7 @@ function Q.shim(q) end, fontToSprite = function(text) return fontToSprite(tostring(text)) end, spriteLoad = function(name) return Q.loadSprite(q, name) end, - -- A sheet, by the engine's order (file, count) or Hypseus's (count, file). + -- A sheet: the engine's spriteLoadFrames(count, file). spriteLoadFrames = function(a, b) if type(a) == "number" then return Q.loadSprite(q, b, a) @@ -7660,22 +9828,24 @@ function Q.shim(q) cfgWritePath = function(name) return singeGetDataPath() .. "Cfg/" .. name end, singeGetScriptPath = function() return q.MYDIR .. "/" .. (q.snapshot.script or "game.singe") end, dofile = function(name) - -- A map-mode game's files are its own data and helpers: loaded into the shim. The - -- engine's own Framework.singe is not: the shim stands in for it, and loading it here - -- would wrap the shim's sprite calls a second time for a game that asks for the old - -- argument order, turning its y into a sprite handle. - if (type(name) == "string") and name:lower():match("^singe[/\\]framework%.singe$") then - return - end + -- A map-mode game's files are its own data and helpers: loaded into the shim. if q.loadsFiles then Q.runScript(q, Q.gameFile(q, name)) + -- The engine's Framework.singe wraps the sprite calls for a game that asks for the + -- old argument order. The shim's spriteDraw and spriteDrawFrame take that order + -- already, so those two wrappers would convert twice and make a y a sprite handle; + -- they go, and the shim's own are found again. The rest of the wrappers stay. + if (type(name) == "string") and name:lower():match("^singe[/\\]framework%.singe$") then + rawset(q, "spriteDraw", nil) + rawset(q, "spriteDrawFrame", nil) + end end end, -- The port keeps no saves and reads no config of its own: a game's save, config, and -- board files come from the description, so its file calls find nothing and write -- nothing (lfs.attributes says the folder is not there). A read is the exception: a - -- game that reads its shipped config as its script loads (the library's Mad Dog McCree - -- and Mad Dog II) would end its load on the empty answer, so a file that is there is + -- game that reads its shipped config as its script loads (two of the gun games do) would + -- end its load on the empty answer, so a file that is there is -- read; what it sets, the description sets again after the load. require = function(name) if name == "lfs" then @@ -7732,7 +9902,7 @@ function Q.shim(q) return _G[key] end, __newindex = function(_, key, value) - -- A script's own MYDIR names the Hypseus layout it came from ("singe/<Title>/"), not + -- A script's own MYDIR names the layout it came from ("singe/<Title>/"), not -- where the port has put it; it is kept apart, for its shape. if key == "MYDIR" then q.gameMYDIR = value @@ -7747,7 +9917,7 @@ end -- Runs a file of the game's Lua in the shim. Answers whether it loaded. function Q.runScript(q, name) -- The game's own files are beside the description first: a built script can share a name - -- with the game's script (Timegal.singe both), and loading the built one here would be + -- with the game's script (one title's does), and loading the built one here would be -- loading the game inside itself. local path = authorBeside(name) local chunk, why = loadfile(path, "t", q.env) @@ -7769,12 +9939,23 @@ end -- ----- The behaviour ------------------------------------------------------------------------- -AUTHOR.behaviours.qte = { - help = "Plays a laserdisc quick-time-event game the KarisFramework way (or the MazescaterFramework's, with its compound moves), from the framework's own tables under game.qte: attract, credits, levels of scenes with moves to answer in a window of frames, death clips, continues, the dips, and the game's own add-ons. Written by util/forgePortKaris.lua from a game's script.", +-- The loops the qte behaviour plays, by the style a description's qte.options names (FORGE.md 15.4). +local QTE_STYLES = {} + + +QTE_STYLES.windows = { + help = "Plays a laserdisc quick-time-event game from the tables under game.qte: attract, credits, levels of scenes with moves to answer in a window of frames (compound moves too: sequences and circles), death clips, continues, the settings, and the game's own add-ons. The port converter writes the tables from an existing game's script.", params = {}, attach = function(instance, params) local data = AUTHOR_GAME.qte or {} - local q = { instance = instance, snapshot = data } + local q = { instance = instance, snapshot = data, qteFns = Q } + + -- How the windows style plays where the shared screens differ; a description's + -- qte.options overrides any of them. + q.optionDefaults = { + managedHud = true, -- The loop sets the HUD by the display setting (every framework copy does). + saveIcon = true -- A disk shows where the game would save. + } instance.q = q instance.hearsAll = true @@ -7785,17 +9966,16 @@ AUTHOR.behaviours.qte = { q[key] = value end end - for key, value in pairs(data.settings or {}) do - q[key] = value - end - for key, value in pairs(data.dips or {}) do - q[key] = value - end - q.dip_Hints = (q.dip_Hints == 1) or (q.dip_Hints == true) - if Q.mazescater(q) then - -- The MazescaterFramework numbers DOUBLE after MULTI. - q.DOUBLE = 34 + Q.applySnapshot(q, data) + -- The settings a service screen saved in an earlier run, over the shipped ones. + Q.configLoad(q) + -- The flags the games' config reader turns from 1 and 0 into true and false (a 0 is + -- true in Lua, so a switched-off one would show). + for _, name in ipairs(QTE_CONFIG_FLAGS) do + q[name] = (q[name] == 1) or (q[name] == true) end + -- Where the compound kinds are in use, DOUBLE is numbered after them. + q.DOUBLE = Q.option(q, "doubleKind", q.DOUBLE) q.env = Q.shim(q) q.MYDIR = (AUTHOR_SOURCE_DIR or AUTHOR_DIR or ""):gsub("[/\\]$", "") -- The tables the framework's globals declare before a game's script fills them. @@ -7815,23 +9995,11 @@ AUTHOR.behaviours.qte = { q.sounds["snd" .. name] = "Sounds/" .. name .. ".wav" q["snd" .. name] = "snd" .. name end - q.highScores = data.highScores or {} - q.iTopN = q.highScores[1] and q.highScores[1][2] or 0 - q.iTopLB, q.iTopS = q.iTopN, q.iTopN - q.iTop = q.iTopN - -- The framework lays itself out on the disc's own size, or half of it. - if discGetWidth and (discGetWidth() > 0) then - if (q.dip_Res or 0) == 0 then - overlaySetResolution(discGetWidth(), discGetHeight()) - else - overlaySetResolution(discGetWidth() / 2, discGetHeight() / 2) - end - end - local extra = q.dip_Extravid or 0 - - q.ShowResurrect = (extra == 1) or (extra == 4) or (extra == 5) or (extra == 7) - q.ShowSupDeath = (extra == 2) or (extra == 4) or (extra == 6) or (extra == 7) - q.ShowLvlClear = (extra == 3) or (extra == 5) or (extra == 6) or (extra == 7) + Q.boardsLoad(q, data) + Q.savesLoad(q) + q.iTop = q.iTopN + Q.applyResolution(q) + Q.applyExtras(q) q.iPenal = ({ [0] = 0, q.PenalNormal or 0, q.PenalHard or 0, q.PenalExtreme or 0 })[q.dip_Difficulty or 1] or 0 q.BarMinT, q.BarBonusT = q.BarMin or 2, q.BarBonus or 3 q.iCoins, q.iCredits, q.iScore, q.iScoreTemp, q.iBonus, q.iScene, q.iExtraLife = 0, 0, 0, 0, 0, 0, 0 @@ -7865,22 +10033,23 @@ AUTHOR.behaviours.qte = { q.currentFrame = discGetFrame() if q.gameflow == "vldp" then -- The framework's start: the title clip once through, a frame of init, the attract loop. + -- The title plays paused: the switches do nothing until it ends. if q.lvlState == QTE.lvlSetup then Q.setupClip(q, q.offsetTitle, q.offsetTitleend) + q.bPause = true q.lvlState = QTE.lvlRunning elseif q.lvlState == QTE.lvlRunning then if q.currentFrame >= q.iFrameEnd then q.lvlState = QTE.lvlEnd end elseif q.lvlState == QTE.lvlEnd then + q.bPause = false q.gameflow = "init" end return end if q.gameflow == "init" then - q.gameflow = "running" - q.lvlState = QTE.lvlSetup - Q.setLang(q) -- initJob's readConfig + Q.initJob(q) return end if q.currentLevel == QTE.levelIntro then @@ -7904,26 +10073,32 @@ AUTHOR.behaviours.qte = { Q.doFinish(q) elseif q.currentLevel == QTE.levelDiffScreen then Q.doDiffSelect(q) + elseif SERVICE_SCREENS[q.currentLevel] then + Q.doServiceScreen(q, SERVICE_SCREENS[q.currentLevel]) end - AUTHOR_VARS.score = q.iScore - AUTHOR_VARS.lives = q.iLives - AUTHOR_VARS.credits = q.iCredits - AUTHOR_VARS.level = q.iLevel - if q.bShowAction and q.move[q.currentMove] then - AUTHOR_VARS.prompt = q.move[q.currentMove][QTE.correctMove] - else - AUTHOR_VARS.prompt = "" - end + Q.afterStep(q) + Q.publishVars(q) end, on = function(instance, name, event) local q = instance.q local flag = qteFlagOf(event.switch or -1) - if flag == nil then + -- Paused (the title), nothing; a release counts only once the game runs. + if (flag == nil) or q.bPause or ((name == "released") and (q.gameflow ~= "running")) then return end if name == "pressed" then q[flag] = true + -- COIN with START held, or START with COIN held, opens the exit screen (not at the + -- continue, where the coin buys one). + if ((flag == "p1COIN1") and q.p1START1) or ((flag == "p1START1") and q.p1COIN1) then + if q.currentLevel ~= QTE.levelContinue then + q[(flag == "p1COIN1") and "p1START1" or "p1COIN1"] = false + Q.leavePlay(q, true) + q.lvlState = QTE.lvlSetup + q.currentLevel = QTE.levelExit + end + end if (flag == "p1BUTTON1") and q.bTestMash then q.iMash = q.iMash + 1 end @@ -7939,9 +10114,8 @@ AUTHOR.behaviours.qte = { if (flag == "p1RIGHT") and q.bTestRunR then q.iMash = q.iMash + 1 end - -- The MazescaterFramework counts the third button and the directions for either - -- side of a mash. - if Q.mazescater(q) and ((flag == "p1BUTTON3") or (flag == "p1UP") or (flag == "p1DOWN") or (flag == "p1LEFT") or (flag == "p1RIGHT")) then + -- mashAnySwitch: the third button and the directions count for either side of a mash. + if Q.option(q, "mashAnySwitch", false) and ((flag == "p1BUTTON3") or (flag == "p1UP") or (flag == "p1DOWN") or (flag == "p1LEFT") or (flag == "p1RIGHT")) then if q.bTestMashR then q.iMash = q.iMash + 1 end @@ -7954,9 +10128,47 @@ AUTHOR.behaviours.qte = { Q.sound(q, "sndroll") end elseif name == "released" then - if (flag == "p1COIN1") or (flag == "p1COIN2") then + if flag == "p1SERVICE" then + -- SERVICE opens the options screen, or closes it back to the attract loop; from + -- play the game is autosaved first, to come back to. + q.p1SERVICE = false + Q.leavePlay(q, false) + q.lvlState = QTE.lvlSetup + if q.currentLevel == QTE.levelService then + q.currentLevel = QTE.levelIntro + else + q.bOneDiff = true + q.currentLevel = QTE.levelService + end + elseif flag == "p1TEST" then + -- TEST opens the save screen, or closes it back to the attract loop. + q.p1TEST = false + Q.leavePlay(q, false) + q.lvlState = QTE.lvlSetup + q.currentLevel = (q.currentLevel == QTE.levelSave) and QTE.levelIntro or QTE.levelSave + elseif flag == "p1CONSOLE" then + q.p1CONSOLE = false + Q.leavePlay(q, false) + q.lvlState = QTE.lvlSetup + q.currentLevel = QTE.levelExit + elseif flag:match("^p1SKILL[123]$") and (q.dip_GameType ~= 5) then + -- SKILL1 to SKILL3 save the game in play to that slot, or with START held load it. + local slot = tonumber(flag:sub(-1)) + + q[flag] = false + if (q.dip_GameType ~= 2) and (q.dip_GameType ~= 3) and (q.currentLevel == QTE.levelNormal) and (q.currentMove <= q.totalMoves) then + if q.p1START1 then + Q.loadSave(q, slot) + q.p1START1 = false + else + Q.autoSave(q, slot) + end + q["bShowDisk" .. slot] = true + q.altState = QTE.branch01 + end + elseif (flag == "p1COIN1") or (flag == "p1COIN2") then q.p1COIN1, q.p1COIN2 = false, false - if (q.currentLevel ~= QTE.levelNormal) and (q.dip_CoinsPerCredit ~= QTE.DOPT_FREEPLAY) and (q.iCredits < 9) then + if (q.currentLevel ~= QTE.levelNormal) and (q.currentLevel ~= QTE.levelService) and (q.dip_CoinsPerCredit ~= QTE.DOPT_FREEPLAY) and (q.iCredits < 9) then q.iCoins = q.iCoins + 1 if q.iCoins >= q.dip_CoinsPerCredit then q.iCoins = q.iCoins - q.dip_CoinsPerCredit @@ -7974,22 +10186,21 @@ AUTHOR.behaviours.qte = { } --- ===== The Kimmy Script Engine game loop ====================================================== +-- ===== The held-input quick-time-event loop ==================================================== -- --- The kimmy behaviour plays a game written for Karis's 2024 successor to the framework the qte --- behaviour plays (FORGE.md section 14.11). The state machine, the screens, and the tables are --- the same family, so the Q helpers above serve both; what Kimmy changed lives here under K: +-- The held style plays a later generation of the games the windows style +-- plays (FORGE.md section 14.11). The state machine, the screens, and the tables are +-- the same family, so the Q helpers above serve both; what changed lives here under K: -- a move judged by a seven-bit mask of what is held (acombo, from the presses) against the mask -- the move asks for (gcombo), which gives combos, diagonals, a second accepted answer, and the -- multi and loop sequences; mash in up/down, left/right, and button-pair variants; a hold of a -- computed length; paths that jump to a later move or a death; yes/no and timed branches; way, -- wayout, toscene, and tolevel jumps; tilt; the life bar; rewind modes 0, 2, and 3; the "die -- and retry" game type that counts deaths instead of points; a new-game menu; a level select. --- The numbers are Kimmy's own, and a run of the original and a run of the port trace alike. +-- The numbers are the later generation's own, and a run of the original and a run of the port trace alike. -local K = {} -- The loop's functions. -local KIMMY = { - -- Kimmy's constants, as its globals.singe declares them; the states and screens are QTE's. +local HELD = { + -- The later generation's constants, as its globals.singe declares them; the states and screens are QTE's. UP = 1, DOWN = 2, LEFT = 3, RIGHT = 4, BUTTON1 = 5, BUTTON2 = 6, BUTTON3 = 7, BUTTON4 = 8, UPLEFT = 9, UPRIGHT = 10, DOWNLEFT = 11, DOWNRIGHT = 12, COMBO = 20, MULTI = 21, LOOP = 22, MASH = 23, HOLD = 24, LETGO = 25, @@ -8003,18 +10214,18 @@ local KIMMY = { branch13 = 22 } -- The masks: one bit per input, in the order the framework keeps them. -local KIMMY_MASK = { +local HELD_MASK = { [1] = { 1, 0, 0, 0, 0, 0, 0 }, [2] = { 0, 1, 0, 0, 0, 0, 0 }, [3] = { 0, 0, 1, 0, 0, 0, 0 }, [4] = { 0, 0, 0, 1, 0, 0, 0 }, [5] = { 0, 0, 0, 0, 1, 0, 0 }, [6] = { 0, 0, 0, 0, 0, 1, 0 }, [7] = { 0, 0, 0, 0, 0, 0, 1 }, [9] = { 1, 0, 1, 0, 0, 0, 0 }, [10] = { 1, 0, 0, 1, 0, 0, 0 }, [11] = { 0, 1, 1, 0, 0, 0, 0 }, [12] = { 0, 1, 0, 1, 0, 0, 0 } } -local KIMMY_BIT = { p1UP = 1, p1DOWN = 2, p1LEFT = 3, p1RIGHT = 4, p1BUTTON1 = 5, p1BUTTON2 = 6, p1BUTTON3 = 7 } +local HELD_BIT = { p1UP = 1, p1DOWN = 2, p1LEFT = 3, p1RIGHT = 4, p1BUTTON1 = 5, p1BUTTON2 = 6, p1BUTTON3 = 7 } local function maskOf(kind) local bits = { 0, 0, 0, 0, 0, 0, 0 } - for i, bit in ipairs(KIMMY_MASK[kind] or {}) do + for i, bit in ipairs(HELD_MASK[kind] or {}) do bits[i] = bit end @@ -8058,12 +10269,17 @@ function K.resetVar(q) end +-- The held style resets the same way everywhere, and judges through its move loop. +K.resetJudge = K.resetVar +K.resetMoveDone = K.resetVar + + function K.resultMove(q) local m = q.move[q.currentMove] if q.thisMove == m[QTE.correctMove] then q.lvlState = QTE.branch04 - elseif q.thisMove ~= KIMMY.MOVEPENDING then + elseif q.thisMove ~= HELD.MOVEPENDING then q.iPauseFrame = m[QTE.inputFrmEnd] q.bShowAction = false q.lvlState = QTE.branch02 @@ -8081,16 +10297,16 @@ function K.fillMove(q, column) end q.bCalc = false q.gcombo = maskOf(m[column]) - if m[QTE.correctMove] <= KIMMY.DOWNRIGHT then + if m[QTE.correctMove] <= HELD.DOWNRIGHT then if m[5] ~= nil then q.g2combo = maskOf(m[5]) - if m[5] == KIMMY.UPRIGHT then + if m[5] == HELD.UPRIGHT then -- The framework's own slip: UP lands on the first mask, not the second. q.g2combo[1] = 0 q.gcombo[1] = 1 end end - elseif m[QTE.correctMove] == KIMMY.COMBO then + elseif m[QTE.correctMove] == HELD.COMBO then for i, bit in ipairs(maskOf(m[6])) do if bit == 1 then q.gcombo[i] = 1 @@ -8107,25 +10323,25 @@ function K.checkAny(q, curMove) return curMove end - return KIMMY.MOVEPENDING + return HELD.MOVEPENDING end function K.checkBasic(q, curMove) local m = q.move[q.currentMove] - local z = KIMMY.MOVEPENDING + local z = HELD.MOVEPENDING local count, extra = maskAgrees(q.acombo, q.gcombo) local count2 = 0 if extra then - z = KIMMY.MOVEFAIL + z = HELD.MOVEFAIL end if m[5] ~= nil then local extra2 count2, extra2 = maskAgrees(q.acombo, q.g2combo) if extra2 then - z = KIMMY.MOVEFAIL + z = HELD.MOVEFAIL end end if (count == 7) or (count2 == 7) then @@ -8143,26 +10359,26 @@ function K.checkCombo(q, curMove) return curMove end - return extra and KIMMY.MOVEFAIL or KIMMY.MOVEPENDING + return extra and HELD.MOVEFAIL or HELD.MOVEPENDING end function K.checkHold(q, curMove) local m = q.move[q.currentMove] - local z = KIMMY.MOVEPENDING + local z = HELD.MOVEPENDING local count, extra = maskAgrees(q.acombo, q.gcombo) if extra then - z = KIMMY.MOVEFAIL + z = HELD.MOVEFAIL end if (q.currentFrame == m[QTE.inputFrmStart]) and (count == 7) then - z = KIMMY.MOVEFAIL + z = HELD.MOVEFAIL elseif (q.currentFrame > m[QTE.inputFrmStart]) and (count == 7) then if q.iLenHold >= q.lenCounter then z = curMove q.iLenHold = 0 else - z = KIMMY.MOVEPENDING + z = HELD.MOVEPENDING if q.bTestHold and (q.currentFrame == q.lastHold) then q.lastHold = q.currentFrame elseif q.bTestHold and (q.currentFrame == q.lastHold + 1) then @@ -8173,13 +10389,13 @@ function K.checkHold(q, curMove) end end elseif count == 5 then - if z ~= KIMMY.NOMOVE then + if z ~= HELD.NOMOVE then q.iLenHold = 0 else if q.iLenHold > 0 then q.iLenHold = q.iLenHold - 1 end - z = KIMMY.MOVEPENDING + z = HELD.MOVEPENDING end end @@ -8187,26 +10403,13 @@ function K.checkHold(q, curMove) end -function K.checkLet(q, playerMove, curMove) - local m = q.move[q.currentMove] - - if (q.currentFrame == m[QTE.inputFrmStart]) and (playerMove == KIMMY.NOMOVE) then - return KIMMY.MOVEFAIL - elseif (q.currentFrame == m[QTE.inputFrmEnd] - 1) and (playerMove == KIMMY.NOMOVE) then - return curMove - end - - return KIMMY.MOVEPENDING -end - - function K.checkMash(q, curMove) - local z = KIMMY.MOVEPENDING + local z = HELD.MOVEPENDING local count, extra = maskAgrees(q.acombo, q.gcombo) if extra then q.iMash = 0 - z = KIMMY.MOVEFAIL + z = HELD.MOVEFAIL end if count == 7 then q.bTestMash, q.bTestRunU, q.bTestRunD, q.bTestRunL, q.bTestRunR = false, false, false, false, false @@ -8223,15 +10426,15 @@ end function K.checkMashBB(q, playerMove, curMove) - local z = KIMMY.MOVEPENDING + local z = HELD.MOVEPENDING - if (playerMove == KIMMY.BUTTON1) and q.bTestMashL then + if (playerMove == HELD.BUTTON1) and q.bTestMashL then q.bTestMashL, q.bTestMashR = false, true if q.iMash >= q.mashCounter then z = curMove q.p1BUTTON1 = false end - elseif (playerMove == KIMMY.BUTTON2) and q.bTestMashR then + elseif (playerMove == HELD.BUTTON2) and q.bTestMashR then q.bTestMashR, q.bTestMashL = false, true if q.iMash >= q.mashCounter then z = curMove @@ -8241,9 +10444,9 @@ function K.checkMashBB(q, playerMove, curMove) if q.iMash > 0 then q.iMash = q.iMash - q.unMash end - if playerMove ~= KIMMY.NOMOVE then + if playerMove ~= HELD.NOMOVE then q.iMash = 0 - z = KIMMY.MOVEFAIL + z = HELD.MOVEFAIL end end @@ -8253,29 +10456,29 @@ end function K.checkMashLR(q, playerMove, curMove) -- LEFT sets bTestRunL false and bTestRunR true; RIGHT the reverse; UP and DOWN are let go. - local z = KIMMY.MOVEPENDING + local z = HELD.MOVEPENDING - if playerMove == KIMMY.LEFT then + if playerMove == HELD.LEFT then q.bTestRunL, q.bTestRunR, q.p1LEFT = false, true, false if q.iMash >= q.mashCounter then z = curMove end - elseif playerMove == KIMMY.RIGHT then + elseif playerMove == HELD.RIGHT then q.bTestRunL, q.bTestRunR, q.p1RIGHT = true, false, false if q.iMash >= q.mashCounter then z = curMove end - elseif playerMove == KIMMY.UP then + elseif playerMove == HELD.UP then q.p1UP = false - elseif playerMove == KIMMY.DOWN then + elseif playerMove == HELD.DOWN then q.p1DOWN = false else if q.iMash > 0 then q.iMash = q.iMash - q.unMash end - if playerMove ~= KIMMY.NOMOVE then + if playerMove ~= HELD.NOMOVE then q.iMash = 0 - z = KIMMY.MOVEFAIL + z = HELD.MOVEFAIL end end @@ -8284,29 +10487,29 @@ end function K.checkMashUD(q, playerMove, curMove) - local z = KIMMY.MOVEPENDING + local z = HELD.MOVEPENDING - if playerMove == KIMMY.UP then + if playerMove == HELD.UP then q.bTestRunU, q.bTestRunD, q.p1UP = false, true, false if q.iMash >= q.mashCounter then z = curMove end - elseif playerMove == KIMMY.DOWN then + elseif playerMove == HELD.DOWN then q.bTestRunU, q.bTestRunD, q.p1DOWN = true, false, false if q.iMash >= q.mashCounter then z = curMove end - elseif playerMove == KIMMY.LEFT then + elseif playerMove == HELD.LEFT then q.p1LEFT = false - elseif playerMove == KIMMY.RIGHT then + elseif playerMove == HELD.RIGHT then q.p1RIGHT = false else if q.iMash > 0 then q.iMash = q.iMash - q.unMash end - if playerMove ~= KIMMY.NOMOVE then + if playerMove ~= HELD.NOMOVE then q.iMash = 0 - z = KIMMY.MOVEFAIL + z = HELD.MOVEFAIL end end @@ -8316,7 +10519,7 @@ end function K.checkMulti(q, curMove) local m = q.move[q.currentMove] - local z = KIMMY.MOVEPENDING + local z = HELD.MOVEPENDING local steps = q.multi[q.currentMove] if (m[6] ~= nil) and (q.currentFrame < m[QTE.inputFrmStart] + m[6]) and (m[QTE.inputFrmStart] + m[6] < m[QTE.inputFrmEnd]) then @@ -8325,7 +10528,7 @@ function K.checkMulti(q, curMove) local count, extra = maskAgrees(q.acombo, q.mcombo) if extra then - z = KIMMY.MOVEFAIL + z = HELD.MOVEFAIL end if (count == 7) and (q.iMulti < #steps) then q.iMulti = q.iMulti + 1 @@ -8340,12 +10543,12 @@ end function K.checkNo(q, curMove) local m = q.move[q.currentMove] - local z = KIMMY.MOVEPENDING + local z = HELD.MOVEPENDING if q.p1UP or q.p1DOWN or q.p1LEFT or q.p1RIGHT or q.p1BUTTON1 or q.p1BUTTON2 or q.p1BUTTON3 then - z = KIMMY.MOVEFAIL + z = HELD.MOVEFAIL end - if (q.currentFrame == m[QTE.inputFrmEnd]) and (z == KIMMY.MOVEPENDING) then + if (q.currentFrame == m[QTE.inputFrmEnd]) and (z == HELD.MOVEPENDING) then z = curMove end @@ -8353,25 +10556,14 @@ function K.checkNo(q, curMove) end -function K.checkSkip(q, playerMove, curMove) - if playerMove ~= KIMMY.NOMOVE then - Q.clearInput(q) - q.bShowAction = false - discSkipToFrame(q.move[q.currentMove][QTE.inputFrmEnd]) - end - - return curMove -end - - -- A branch taken: a death when the target is past OUT, else the moves to skip and where to. local function takeBranch(q, target, endAt, jumpTo) local m = q.move[q.currentMove] q.iPath = target q.bShowAction = false - if target > KIMMY.OUT then - m[QTE.moveDeath] = target - KIMMY.OUT + if target > HELD.OUT then + m[QTE.moveDeath] = target - HELD.OUT q.lvlState = QTE.branch02 else q.iPathAend = endAt @@ -8383,12 +10575,12 @@ end -- ----- The move being waited for, judged by its kind ----------------------------------------- -function K.doMove(q) +function K.judge(q) local m = q.move[q.currentMove] local kind = m[QTE.correctMove] local fps = q.MovieFPS - if kind == KIMMY.HOLD then + if kind == HELD.HOLD then q.bTestHold = true if q.bCalc then q.lenCounter = (m[QTE.inputFrmEnd] - m[QTE.inputFrmStart]) - (14 * (fps / 24) - q.dip_Difficulty) @@ -8396,30 +10588,30 @@ function K.doMove(q) K.fillMove(q, 5) q.thisMove = K.checkHold(q, kind) K.resultMove(q) - elseif kind == KIMMY.LETGO then - q.thisMove = K.checkLet(q, Q.scanInput(q), kind) + elseif kind == HELD.LETGO then + q.thisMove = Q.checkLet(q, Q.scanInput(q), kind) K.resultMove(q) - elseif kind == KIMMY.MASH then + elseif kind == HELD.MASH then local with = m[5] q.thisMove = Q.scanInput(q) - if (with == nil) or (with == KIMMY.BUTTON1) or (with == KIMMY.BUTTON2) or (with == KIMMY.BUTTON3) then + if (with == nil) or (with == HELD.BUTTON1) or (with == HELD.BUTTON2) or (with == HELD.BUTTON3) then q.bTestMash = true - elseif with == KIMMY.UP then + elseif with == HELD.UP then q.bTestRunU = true - elseif with == KIMMY.DOWN then + elseif with == HELD.DOWN then q.bTestRunD = true - elseif with == KIMMY.LEFT then + elseif with == HELD.LEFT then q.bTestRunL = true - elseif with == KIMMY.RIGHT then + elseif with == HELD.RIGHT then q.bTestRunR = true - elseif with == KIMMY.UPLEFT then + elseif with == HELD.UPLEFT then q.bTestRunU, q.bTestRunL = true, true - elseif with == KIMMY.UPRIGHT then + elseif with == HELD.UPRIGHT then q.bTestRunU, q.bTestRunR = true, true - elseif with == KIMMY.DOWNLEFT then + elseif with == HELD.DOWNLEFT then q.bTestRunD, q.bTestRunL = true, true - elseif with == KIMMY.DOWNRIGHT then + elseif with == HELD.DOWNRIGHT then q.bTestRunD, q.bTestRunR = true, true end if q.bCalc then @@ -8427,87 +10619,87 @@ function K.doMove(q) q.unMash = (q.dip_MashRes + adjust) * (0.014 + q.dip_Difficulty / 500) q.mashCounter = ((m[QTE.inputFrmEnd] - m[QTE.inputFrmStart]) / fps) * 3 - if (with >= KIMMY.UPLEFT) and (with <= KIMMY.DOWNRIGHT) then + if (with >= HELD.UPLEFT) and (with <= HELD.DOWNRIGHT) then q.mashCounter = q.mashCounter * 2 q.unMash = q.unMash * 1.5 - elseif with == KIMMY.UD then + elseif with == HELD.UD then q.bTestRunU, q.bTestRunD = true, true q.mashCounter = q.mashCounter * ((q.dip_MashtoRun == 1) and 1.5 or 2.5) - elseif with == KIMMY.LR then + elseif with == HELD.LR then q.bTestRunL, q.bTestRunR = true, true q.mashCounter = q.mashCounter * ((q.dip_MashtoRun == 1) and 1.5 or 2.5) - elseif with == KIMMY.B1B2 then + elseif with == HELD.B1B2 then q.bTestMashL, q.bTestMashR = true, true q.mashCounter = q.mashCounter * 2.5 end end - if with == KIMMY.UD then + if with == HELD.UD then q.thisMove = K.checkMashUD(q, q.thisMove, kind) - elseif with == KIMMY.LR then + elseif with == HELD.LR then q.thisMove = K.checkMashLR(q, q.thisMove, kind) - elseif with == KIMMY.B1B2 then + elseif with == HELD.B1B2 then q.thisMove = K.checkMashBB(q, q.thisMove, kind) else K.fillMove(q, 5) q.thisMove = K.checkMash(q, kind) end K.resultMove(q) - elseif kind <= KIMMY.DOWNRIGHT then - q.thisMove = KIMMY.NOMOVE + elseif kind <= HELD.DOWNRIGHT then + q.thisMove = HELD.NOMOVE K.fillMove(q, 3) q.thisMove = K.checkBasic(q, kind) K.resultMove(q) - elseif kind == KIMMY.ANYTHING then + elseif kind == HELD.ANYTHING then q.thisMove = K.checkAny(q, kind) K.resultMove(q) - elseif kind == KIMMY.NOTHING then + elseif kind == HELD.NOTHING then q.thisMove = K.checkNo(q, kind) K.resultMove(q) - elseif kind == KIMMY.COMBO then + elseif kind == HELD.COMBO then K.fillMove(q, 5) q.thisMove = K.checkCombo(q, kind) K.resultMove(q) - elseif (kind == KIMMY.MULTI) or (kind == KIMMY.LOOP) then + elseif (kind == HELD.MULTI) or (kind == HELD.LOOP) then q.bTestCombo, q.bTestMulti = true, true - if kind == KIMMY.LOOP then + if kind == HELD.LOOP then q.bCalc = false -- The framework picks a loop picture here; the port draws none. end q.thisMove = Q.scanInput(q) q.mcombo = maskOf(q.multi[q.currentMove][q.iMulti]) q.thisMove = K.checkMulti(q, kind) K.resultMove(q) - elseif kind == KIMMY.PATH then + elseif kind == HELD.PATH then q.thisMove = Q.scanInput(q) - if q.thisMove ~= KIMMY.NOMOVE then + if q.thisMove ~= HELD.NOMOVE then local p = q.path[q.currentMove] if q.thisMove == p[1] then takeBranch(q, p[2], p[4] - 1, p[9]) elseif q.thisMove == p[3] then if p[5] == 0 then - takeBranch(q, p[4], 0, (p[9] == KIMMY.OUT) and KIMMY.OUT or 0) + takeBranch(q, p[4], 0, (p[9] == HELD.OUT) and HELD.OUT or 0) else takeBranch(q, p[4], p[6] - 1, p[9]) end elseif q.thisMove == p[5] then if p[7] == 0 then - takeBranch(q, p[6], 0, (p[9] == KIMMY.OUT) and KIMMY.OUT or 0) + takeBranch(q, p[6], 0, (p[9] == HELD.OUT) and HELD.OUT or 0) else takeBranch(q, p[6], p[8] - 1, p[9]) end elseif q.thisMove == p[7] then - takeBranch(q, p[8], 0, (p[9] == KIMMY.OUT) and KIMMY.OUT or 0) + takeBranch(q, p[8], 0, (p[9] == HELD.OUT) and HELD.OUT or 0) else q.bShowAction = false q.lvlState = QTE.branch02 end end - elseif kind == KIMMY.YESNO then + elseif kind == HELD.YESNO then local p = q.path[q.currentMove] q.thisMove = Q.scanInput(q) - if q.thisMove ~= KIMMY.NOMOVE then - if q.thisMove == KIMMY.BUTTON1 then + if q.thisMove ~= HELD.NOMOVE then + if q.thisMove == HELD.BUTTON1 then takeBranch(q, p[1], p[2] - 1, p[3]) else q.bShowAction = false @@ -8516,11 +10708,11 @@ function K.doMove(q) elseif q.currentFrame == m[QTE.inputFrmEnd] then takeBranch(q, p[2], 0, 0) end - elseif kind == KIMMY.TIMED then + elseif kind == HELD.TIMED then local i, j = m[5] or q.currentMove, m[6] or q.currentMove q.thisMove = Q.scanInput(q) - if q.thisMove ~= KIMMY.NOMOVE then + if q.thisMove ~= HELD.NOMOVE then if q.bTime then q.bTime = false q.Hit = q.currentFrame @@ -8548,8 +10740,8 @@ function K.doMove(q) end end end - elseif kind == KIMMY.SKIP then - q.thisMove = K.checkSkip(q, Q.scanInput(q), kind) + elseif kind == HELD.SKIP then + q.thisMove = Q.checkSkip(q, Q.scanInput(q), kind) end end @@ -8557,10 +10749,10 @@ end -- ----- A scene's moves, made ready: shortcuts, difficulty, mirroring, frames ------------------ -- A direction or button named in a move's text ("U", "B2", "dl"). -local KIMMY_WORD = { U = 1, D = 2, L = 3, R = 4, B1 = 5, B2 = 6, B3 = 7, UL = 9, UR = 10, DL = 11, DR = 12 } +local HELD_WORD = { U = 1, D = 2, L = 3, R = 4, B1 = 5, B2 = 6, B3 = 7, UL = 9, UR = 10, DL = 11, DR = 12 } local function wordOf(text) - return KIMMY_WORD[string.upper(text)] + return HELD_WORD[string.upper(text)] end @@ -8583,60 +10775,60 @@ function K.getShortcuts(q, at) local m = q.move[at] local kind = m[3] - if kind == KIMMY.ACTUP then - m[3], m[5], m[6] = KIMMY.COMBO, KIMMY.BUTTON1, KIMMY.UP - elseif kind == KIMMY.ACTDOWN then - m[3], m[5], m[6] = KIMMY.COMBO, KIMMY.BUTTON1, KIMMY.DOWN - elseif kind == KIMMY.ACTLEFT then - m[3], m[5], m[6] = KIMMY.COMBO, KIMMY.BUTTON1, KIMMY.LEFT - elseif kind == KIMMY.ACTRIGHT then - m[3], m[5], m[6] = KIMMY.COMBO, KIMMY.BUTTON1, KIMMY.RIGHT - elseif kind == KIMMY.HOLDUP then - m[3], m[5] = KIMMY.HOLD, KIMMY.UP - elseif kind == KIMMY.HOLDDOWN then - m[3], m[5] = KIMMY.HOLD, KIMMY.DOWN - elseif kind == KIMMY.HOLDLEFT then - m[3], m[5] = KIMMY.HOLD, KIMMY.LEFT - elseif kind == KIMMY.HOLDRIGHT then - m[3], m[5] = KIMMY.HOLD, KIMMY.RIGHT - elseif kind == KIMMY.HOLDBUT then - m[3], m[5] = KIMMY.HOLD, KIMMY.BUTTON1 - elseif kind == KIMMY.RUN then - m[3], m[5] = KIMMY.MASH, KIMMY.LR - elseif kind == KIMMY.MASH2 then - m[3], m[5] = KIMMY.MASH, KIMMY.B1B2 - elseif kind == KIMMY.MASHMIN then - m[3], m[5], m[6] = KIMMY.MASH, KIMMY.BUTTON1, -2 - elseif kind == KIMMY.MASHMAX then - m[3], m[5], m[6] = KIMMY.MASH, KIMMY.BUTTON1, 2 - elseif kind == KIMMY.MASH2MIN then - m[3], m[5], m[6] = KIMMY.MASH, KIMMY.B1B2, -2 - elseif kind == KIMMY.MASH2MAX then - m[3], m[5], m[6] = KIMMY.MASH, KIMMY.B1B2, 2 - elseif (kind == KIMMY.MASH) and (m[5] == nil) then - m[5] = KIMMY.BUTTON1 - elseif (kind == KIMMY.MASH) and (m[5] == KIMMY.DU) then - m[5] = KIMMY.UD - elseif (kind == KIMMY.MASH) and (m[5] == KIMMY.RL) then - m[5] = KIMMY.LR - elseif kind == KIMMY.RUNMIN then - m[3], m[5], m[6] = KIMMY.MASH, KIMMY.LR, -2 - elseif kind == KIMMY.RUNMAX then - m[3], m[5], m[6] = KIMMY.MASH, KIMMY.LR, 2 - elseif (kind == KIMMY.LOOP) or (kind == KIMMY.LOOPLEFT) or (kind == KIMMY.LOOPRIGHT) then + if kind == HELD.ACTUP then + m[3], m[5], m[6] = HELD.COMBO, HELD.BUTTON1, HELD.UP + elseif kind == HELD.ACTDOWN then + m[3], m[5], m[6] = HELD.COMBO, HELD.BUTTON1, HELD.DOWN + elseif kind == HELD.ACTLEFT then + m[3], m[5], m[6] = HELD.COMBO, HELD.BUTTON1, HELD.LEFT + elseif kind == HELD.ACTRIGHT then + m[3], m[5], m[6] = HELD.COMBO, HELD.BUTTON1, HELD.RIGHT + elseif kind == HELD.HOLDUP then + m[3], m[5] = HELD.HOLD, HELD.UP + elseif kind == HELD.HOLDDOWN then + m[3], m[5] = HELD.HOLD, HELD.DOWN + elseif kind == HELD.HOLDLEFT then + m[3], m[5] = HELD.HOLD, HELD.LEFT + elseif kind == HELD.HOLDRIGHT then + m[3], m[5] = HELD.HOLD, HELD.RIGHT + elseif kind == HELD.HOLDBUT then + m[3], m[5] = HELD.HOLD, HELD.BUTTON1 + elseif kind == HELD.RUN then + m[3], m[5] = HELD.MASH, HELD.LR + elseif kind == HELD.MASH2 then + m[3], m[5] = HELD.MASH, HELD.B1B2 + elseif kind == HELD.MASHMIN then + m[3], m[5], m[6] = HELD.MASH, HELD.BUTTON1, -2 + elseif kind == HELD.MASHMAX then + m[3], m[5], m[6] = HELD.MASH, HELD.BUTTON1, 2 + elseif kind == HELD.MASH2MIN then + m[3], m[5], m[6] = HELD.MASH, HELD.B1B2, -2 + elseif kind == HELD.MASH2MAX then + m[3], m[5], m[6] = HELD.MASH, HELD.B1B2, 2 + elseif (kind == HELD.MASH) and (m[5] == nil) then + m[5] = HELD.BUTTON1 + elseif (kind == HELD.MASH) and (m[5] == HELD.DU) then + m[5] = HELD.UD + elseif (kind == HELD.MASH) and (m[5] == HELD.RL) then + m[5] = HELD.LR + elseif kind == HELD.RUNMIN then + m[3], m[5], m[6] = HELD.MASH, HELD.LR, -2 + elseif kind == HELD.RUNMAX then + m[3], m[5], m[6] = HELD.MASH, HELD.LR, 2 + elseif (kind == HELD.LOOP) or (kind == HELD.LOOPLEFT) or (kind == HELD.LOOPRIGHT) then -- "U,+,1,B2": the first direction, the way round, the turns (Q a quarter, H a half), -- and a button to finish on. - if kind == KIMMY.LOOPLEFT then - m[3], m[5] = KIMMY.LOOP, "L,+,1" - elseif kind == KIMMY.LOOPRIGHT then - m[3], m[5] = KIMMY.LOOP, "R,-,1" + if kind == HELD.LOOPLEFT then + m[3], m[5] = HELD.LOOP, "L,+,1" + elseif kind == HELD.LOOPRIGHT then + m[3], m[5] = HELD.LOOP, "R,-,1" end local text = m[5] local turns = string.sub(text, 5, 5) local steps = { wordOf(string.sub(text, 1, 1)) } local count = (turns == "Q") and 2 or ((turns == "H") and 3 or (4 * tonumber(turns) + 1)) - local clockwise = { [KIMMY.UP] = KIMMY.RIGHT, [KIMMY.RIGHT] = KIMMY.DOWN, [KIMMY.DOWN] = KIMMY.LEFT, [KIMMY.LEFT] = KIMMY.UP } - local widdershins = { [KIMMY.UP] = KIMMY.LEFT, [KIMMY.LEFT] = KIMMY.DOWN, [KIMMY.DOWN] = KIMMY.RIGHT, [KIMMY.RIGHT] = KIMMY.UP } + local clockwise = { [HELD.UP] = HELD.RIGHT, [HELD.RIGHT] = HELD.DOWN, [HELD.DOWN] = HELD.LEFT, [HELD.LEFT] = HELD.UP } + local widdershins = { [HELD.UP] = HELD.LEFT, [HELD.LEFT] = HELD.DOWN, [HELD.DOWN] = HELD.RIGHT, [HELD.RIGHT] = HELD.UP } local turn = (string.sub(text, 3, 3) == "+") and clockwise or widdershins for i = 2, count do @@ -8644,11 +10836,11 @@ function K.getShortcuts(q, at) end local button = wordOf(string.sub(text, 7, 8)) - if button and (button >= KIMMY.BUTTON1) and (button <= KIMMY.BUTTON3) then + if button and (button >= HELD.BUTTON1) and (button <= HELD.BUTTON3) then steps[count + 1] = button end q.multi[at] = steps - elseif (kind == KIMMY.MULTI) and (m[6] ~= nil) and (string.find(tostring(m[5]), ",") == nil) then + elseif (kind == HELD.MULTI) and (m[6] ~= nil) and (string.find(tostring(m[5]), ",") == nil) then -- The same input so many times. local steps = {} @@ -8657,14 +10849,14 @@ function K.getShortcuts(q, at) end q.multi[at] = steps m[6] = nil - elseif kind == KIMMY.MULTI then + elseif kind == HELD.MULTI then local steps = {} for i, word in ipairs(wordsOf(m[5])) do - steps[i] = wordOf(word) or KIMMY.BUTTON1 + steps[i] = wordOf(word) or HELD.BUTTON1 end q.multi[at] = steps - elseif (kind == KIMMY.PATH) and (m[5] ~= nil) then + elseif (kind == HELD.PATH) and (m[5] ~= nil) then -- "U,10,D,11,L,12,R,14": a direction and the move it leads to, in pairs; the ninth -- slot is where the branch rejoins, or OUT. local p = { 0, 0, 0, 0, 0, 0, 0, 0, 0 } @@ -8678,34 +10870,34 @@ function K.getShortcuts(q, at) end slot = slot + 1 end - p[9] = m[6] or KIMMY.OUT + p[9] = m[6] or HELD.OUT q.path[at] = p - elseif (kind == KIMMY.YESNO) and (m[5] ~= nil) then + elseif (kind == HELD.YESNO) and (m[5] ~= nil) then local p = { 0, 0, 0 } for i, word in ipairs(wordsOf(m[5])) do p[i] = tonumber(word) end - p[3] = m[6] or KIMMY.OUT + p[3] = m[6] or HELD.OUT q.path[at] = p - elseif kind == KIMMY.DOUBLE then - m[3], q.multi[at] = KIMMY.MULTI, { KIMMY.BUTTON1, KIMMY.BUTTON1 } - elseif kind == KIMMY.UD then - m[3], q.multi[at] = KIMMY.MULTI, { KIMMY.UP, KIMMY.DOWN } - elseif kind == KIMMY.DU then - m[3], q.multi[at] = KIMMY.MULTI, { KIMMY.DOWN, KIMMY.UP } - elseif kind == KIMMY.LR then - m[3], q.multi[at] = KIMMY.MULTI, { KIMMY.LEFT, KIMMY.RIGHT } - elseif kind == KIMMY.RL then - m[3], q.multi[at] = KIMMY.MULTI, { KIMMY.RIGHT, KIMMY.LEFT } + elseif kind == HELD.DOUBLE then + m[3], q.multi[at] = HELD.MULTI, { HELD.BUTTON1, HELD.BUTTON1 } + elseif kind == HELD.UD then + m[3], q.multi[at] = HELD.MULTI, { HELD.UP, HELD.DOWN } + elseif kind == HELD.DU then + m[3], q.multi[at] = HELD.MULTI, { HELD.DOWN, HELD.UP } + elseif kind == HELD.LR then + m[3], q.multi[at] = HELD.MULTI, { HELD.LEFT, HELD.RIGHT } + elseif kind == HELD.RL then + m[3], q.multi[at] = HELD.MULTI, { HELD.RIGHT, HELD.LEFT } end end -local KIMMY_SIDE = { [KIMMY.LEFT] = KIMMY.RIGHT, [KIMMY.RIGHT] = KIMMY.LEFT, [KIMMY.UPLEFT] = KIMMY.UPRIGHT, [KIMMY.UPRIGHT] = KIMMY.UPLEFT, [KIMMY.DOWNLEFT] = KIMMY.DOWNRIGHT, [KIMMY.DOWNRIGHT] = KIMMY.DOWNLEFT } +local HELD_SIDE = { [HELD.LEFT] = HELD.RIGHT, [HELD.RIGHT] = HELD.LEFT, [HELD.UPLEFT] = HELD.UPRIGHT, [HELD.UPRIGHT] = HELD.UPLEFT, [HELD.DOWNLEFT] = HELD.DOWNRIGHT, [HELD.DOWNRIGHT] = HELD.DOWNLEFT } local function mirrored(value) - return KIMMY_SIDE[value] or value + return HELD_SIDE[value] or value end @@ -8719,90 +10911,90 @@ function K.setupFramesMoves(q) local m = move[at] if q.dip_GameType == 5 then - if not ((m[3] >= KIMMY.CHOOSE) and (m[3] <= KIMMY.YESNO)) then - m[3] = KIMMY.BUTTON1 + if not ((m[3] >= HELD.CHOOSE) and (m[3] <= HELD.YESNO)) then + m[3] = HELD.BUTTON1 end else K.getShortcuts(q, at) if q.dip_Difficulty == 0 then - if m[3] == KIMMY.HOLD then + if m[3] == HELD.HOLD then m[3] = m[5] - elseif (m[3] == KIMMY.MASH) or (m[3] == KIMMY.LETGO) then - m[3] = KIMMY.BUTTON1 - elseif (m[3] == KIMMY.LOOP) or (m[3] == KIMMY.MULTI) then + elseif (m[3] == HELD.MASH) or (m[3] == HELD.LETGO) then + m[3] = HELD.BUTTON1 + elseif (m[3] == HELD.LOOP) or (m[3] == HELD.MULTI) then m[3], m[5] = q.multi[at][1], nil - elseif m[3] == KIMMY.COMBO then + elseif m[3] == HELD.COMBO then m[3] = m[5] end else if q.dip_MashtoRun == 2 then - if (m[3] == KIMMY.MASH) and ((m[5] == KIMMY.UD) or (m[5] == KIMMY.LR)) then - m[5] = KIMMY.B1B2 + if (m[3] == HELD.MASH) and ((m[5] == HELD.UD) or (m[5] == HELD.LR)) then + m[5] = HELD.B1B2 end elseif q.dip_MashtoRun == 3 then - if (m[3] == KIMMY.MASH) and ((m[5] == KIMMY.UD) or (m[5] == KIMMY.LR) or (m[5] == KIMMY.B1B2)) then - m[3], m[5] = KIMMY.MASH, KIMMY.BUTTON1 + if (m[3] == HELD.MASH) and ((m[5] == HELD.UD) or (m[5] == HELD.LR) or (m[5] == HELD.B1B2)) then + m[3], m[5] = HELD.MASH, HELD.BUTTON1 end end - if (q.dip_HoldtoLoop == 1) and (m[3] == KIMMY.LOOP) then + if (q.dip_HoldtoLoop == 1) and (m[3] == HELD.LOOP) then local way = string.sub(m[5], 3, 3) if way == "+" then - m[3], m[5] = KIMMY.HOLD, KIMMY.RIGHT + m[3], m[5] = HELD.HOLD, HELD.RIGHT elseif way == "-" then - m[3], m[5] = KIMMY.HOLD, KIMMY.LEFT + m[3], m[5] = HELD.HOLD, HELD.LEFT end end end end if q.bFlip then - if KIMMY_SIDE[m[3]] then - m[3] = KIMMY_SIDE[m[3]] - elseif ((m[3] == KIMMY.HOLD) or (m[3] == KIMMY.MASH)) and KIMMY_SIDE[m[5]] then - m[5] = KIMMY_SIDE[m[5]] - elseif m[3] == KIMMY.PATH then + if HELD_SIDE[m[3]] then + m[3] = HELD_SIDE[m[3]] + elseif ((m[3] == HELD.HOLD) or (m[3] == HELD.MASH)) and HELD_SIDE[m[5]] then + m[5] = HELD_SIDE[m[5]] + elseif m[3] == HELD.PATH then local p = q.path[at] for _, slot in ipairs({ 1, 3, 5, 7 }) do - if (p[slot] == KIMMY.LEFT) or (p[slot] == KIMMY.RIGHT) then - p[slot] = KIMMY_SIDE[p[slot]] + if (p[slot] == HELD.LEFT) or (p[slot] == HELD.RIGHT) then + p[slot] = HELD_SIDE[p[slot]] end end - elseif m[3] == KIMMY.TIMED then + elseif m[3] == HELD.TIMED then local from, to = m[5] or at, m[6] or at for p = from, to do local t = q.timed[p] - if (t[1] == KIMMY.LEFT) or (t[1] == KIMMY.RIGHT) then - t[1] = KIMMY_SIDE[t[1]] + if (t[1] == HELD.LEFT) or (t[1] == HELD.RIGHT) then + t[1] = HELD_SIDE[t[1]] end t[2] = t[2] + q.offsetFlip t[3] = t[3] + q.offsetFlip end - elseif m[3] == KIMMY.COMBO then + elseif m[3] == HELD.COMBO then m[5] = mirrored(m[5]) m[6] = mirrored(m[6]) - elseif m[3] == KIMMY.MULTI then + elseif m[3] == HELD.MULTI then for i, step in ipairs(q.multi[at]) do q.multi[at][i] = mirrored(step) end - elseif m[3] == KIMMY.LOOP then + elseif m[3] == HELD.LOOP then for i, step in ipairs(q.multi[at]) do - if (step == KIMMY.LEFT) or (step == KIMMY.RIGHT) then - q.multi[at][i] = KIMMY_SIDE[step] + if (step == HELD.LEFT) or (step == HELD.RIGHT) then + q.multi[at][i] = HELD_SIDE[step] end end end end -- The frames the move plays from and to: after the move before, unless a jump kind -- put that one later than this begins. - local jump = (m[3] == KIMMY.WAY) or (m[3] == KIMMY.WAYOUT) or (m[3] == KIMMY.TOSCENE) or (m[3] == KIMMY.TOLEVEL) + local jump = (m[3] == HELD.WAY) or (m[3] == HELD.WAYOUT) or (m[3] == HELD.TOSCENE) or (m[3] == HELD.TOLEVEL) local before = move[at - 1] if (at == 1) or jump then m[7] = m[1] - elseif ((before[3] == KIMMY.WAY) or (m[3] == KIMMY.WAYOUT) or (m[3] == KIMMY.TOSCENE) or (m[3] == KIMMY.TOLEVEL)) and (before[8] > m[1]) then + elseif ((before[3] == HELD.WAY) or (m[3] == HELD.WAYOUT) or (m[3] == HELD.TOSCENE) or (m[3] == HELD.TOLEVEL)) and (before[8] > m[1]) then m[7] = move[at - 2][2] + 1 else m[7] = before[2] + 1 @@ -8829,404 +11021,11 @@ function K.setupFrames(q, thisLevel) end -function K.setupLevel(q, thisLevel) - local level = q.Level[thisLevel] - - q.iScene = q.iScene + 1 - q.iPath, q.iPathAjmp, q.iPathAend = 0, 0, 0 - if q.swapScene then - q.swapScene() - else - -- The same answers, read from the description: the converter asked the game's swapScene - -- for every difficulty, level and scene, and kept the ones that differ. A game that still - -- carries the hook runs it, since a hook can be edited to do more than this describes. - local byLevel = (q.snapshot.swaps or {})[q.dip_Difficulty or 0] - local scene = byLevel and byLevel[thisLevel] and byLevel[thisLevel][q.iScene] - - if scene then - q.bSwap = true - q.iScene = scene - end - end - q.bFlip = false - if q.iScene > level[QTE.TOTALSCENES] then - q.iScene = q.iScene - 1 - end - if level[QTE.MIRROR] > 0 then - if math.random(100) <= 50 then - q.bFlip = true - end - end - q.multi = {} - Q.loadMoves(q, thisLevel, q.iScene) - K.setupFrames(q, thisLevel) - if q.bAllowSave and (q.dip_GameType ~= 2) and (q.dip_GameType ~= 3) and (q.i2P == 0) then - -- The framework autosaves here; a save is not play, and the port keeps none. - q.bShowDiskA = true - q.altState = QTE.branch01 - q.bAllowSave = false - end -end - - -- ----- Scoring, deaths, and the next level ---------------------------------------------------- -function K.addPoints(q, thisMuch, thisValue) - if q.specialScore then - q.specialScore(thisValue) - end - if q.dip_GameType == 4 then - return - end - q.thisScore = 0 - if q.dip_GameType ~= 3 then - local worth = (q.thisScore == 0) and thisMuch or q.thisScore - - q.iScore = q.iScore + worth - q.iExtraLife = q.iExtraLife + worth - if (q.EXTRALIFE > 0) and (q.iExtraLife >= q.EXTRALIFE) and (q.dip_GameType == 0) and ((q.i2P == 0) or q.hayate) then - q.iExtraLife = 0 - if q.iLives < q.dip_LivesPerCredit then - Q.sound(q, "sndvictory") - q.iLives = q.iLives + 1 - end - end - if (q.dip_GameType == 1) and (q.iRightMv == q.BarBonusT) then - q.iRightMv, q.iTilt = 0, 0 - q.bShowWarnTilt, q.bShowTilt = false, false - if (q.BarBonusT ~= 0) and (q.iLifeBar < q.BarSize) then - q.iLifeBar = q.iLifeBar + 1 - end - end - elseif thisValue == 1 then - q.iScore = q.iScore + 1 - end - if q.iScore > q.iTop then - q.iTop = q.iScore - end - if q.iScore > 99999999 then - q.iScore = 99999999 - end -end - - --- Whether a result makes the board: fewer deaths than the "die and retry" record, or a score --- among the ten. -function K.newScore(q, score) - if q.dip_GameType == 4 then - return score <= q.hsDR[q.dip_Difficulty + 1] - end - - return Q.newScore(q, score) -end - - -function K.setupDeathClip(q) - local level = q.Level[q.iLevel] - local m = q.move[q.currentMove] - - q.curDeath = m[QTE.moveDeath] - if q.swapDeath then - q.swapDeath() - end - q.bShowLvl, q.bShowNext, q.bShowWarnTilt = false, false, false - K.resetVar(q) - if q.curDeath < 0 then - q.lvlState = QTE.lvlPlayRest - return - end - q.lvlState = QTE.lvlPlayDeath - if q.dip_GameType ~= 4 then - q.iLives = q.iLives - 1 - end - if q.i2P == 1 then - if q.iLives == 0 then - q.b1PEnd, q.i1PScore = true, q.iScore - end - elseif q.i2P == 2 then - if q.iLives == 0 then - q.b2PEnd, q.i2PScore = true, q.iScore - end - end - if m[QTE.moveDeath] == 0 then - q.curDeath = math.random(q.totalDeath) - end - local clip = q.Death[q.curDeath] - - if q.bFlip then - Q.setupClip(q, clip[1] + level[QTE.DTHMIRROR], clip[2] + level[QTE.DTHMIRROR]) - else - Q.setupClip(q, clip[1], clip[2]) - end - if not q.dip_Hints and (q.dip_GameType ~= 1) then - Q.sound(q, "sndwrong") - end - if q.dip_Rewind == 1 then - if q.currentMove == 1 then - q.iPauseFrame = m[QTE.inputFrmStart] - 15 - q.currentMove = 0 - elseif m[QTE.correctMove] == KIMMY.CHOOSE then - m[QTE.moveDeath] = q.numChoice - q.iPauseFrame = q.move[q.currentMove - 2][QTE.inputFrmEnd] + 1 - q.currentMove = q.currentMove - 2 - elseif m[QTE.correctMove] == KIMMY.LETGO then - q.iPauseFrame = q.move[q.currentMove - 1][QTE.inputFrmStart] - 15 - q.currentMove = q.currentMove - 2 - else - q.iPauseFrame = m[QTE.inputFrmStart] - 15 - q.currentMove = q.currentMove - 1 - end - end -end - - -function K.nextLevel(q, thisLevel) - q.iScPlayed = 0 - q.bLvlJump = false - if q.hayate or not q.MazeGame then - q.iScDeath = 0 - end - if q.dip_GameType ~= 4 then - q.iTotDeath = 0 - end - if q.dip_PlayStyle == 3 then - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelMap - q.iScene = 0 - elseif q.iPath ~= 0 then - q.iLevel = q.iPath - q.iPath = 0 - q.iScene = 0 - elseif not q.MazeGame then - for k = 1, q.finalstage - 1 do - if q.LvlOrder[k] == thisLevel then - q.iLevel = q.LvlOrder[k + 1] - q.iScene = 0 - break - end - end - end - if q.swapLevel then - q.swapLevel() - end -end - - --- On from a level: the map, the level select (play style 4), or the next level in order. -function K.onToNextLevel(q, reordering) - q.bSkipIntroClip = false - q.iLiveSave, q.iScoreSave = q.iLives, q.iScore - q.bAllowSave, q.bRes = true, true - q.iScoreTemp, q.iBonus = 0, 0 - if q.dip_PlayStyle == 3 then - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelMap - elseif reordering then - Q.reOrder(q, q.iLevel) - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelNormal - elseif q.dip_PlayStyle == 4 then - q.dip_StartLevel = q.dip_StartLevel + 1 - q.iScene = 0 - q.altState = QTE.lvlSetup - q.currentLevel = KIMMY.levelSelect - else - K.nextLevel(q, q.iLevel) - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelNormal - end -end - - -function K.levelReplay(q) - local replay = q.Level[q.iLevel][QTE.LVLREPLAY] - - if replay == 0 then - K.onToNextLevel(q, false) - elseif replay == 1 then - if not q.stage[q.iLevel][QTE.LEVELSTARTED] then - q.stage[q.iLevel][QTE.LEVELSTARTED] = true - else - K.onToNextLevel(q, false) - end - elseif (replay > 1) or (replay < -1) then - if not q.stage[q.iLevel][QTE.LEVELSTARTED] then - q.stage[q.iLevel][QTE.LEVELSTARTED] = true - K.onToNextLevel(q, true) - else - K.onToNextLevel(q, false) - end - end -end - - -- ----- The level: scenes, moves, deaths, and what follows them -------------------------------- -- The score, lives, and level flags as the display dip and the game type set them. -local function showPlayFlags(q) - if q.dip_GameType == 2 then - q.bShowScore, q.bShowLives = false, false - elseif q.dip_Display == 0 then - q.bShowScore, q.bShowLives, q.bShowLvl = true, true, true - q.bShowTop = q.ShowTop and true or false - else - q.bShowScore, q.bShowLives, q.bShowLvl, q.bShowTop = false, false, false, false - end -end - - --- Into the scene: from a save, through the level's intro clip, or straight to its start. -function K.enterScene(q, thisLevel) - local level = q.Level[thisLevel] - - if q.bSave and (q.currentMove ~= 1) then - q.currentFrame = q.move[q.currentMove - 1][QTE.inputFrmEnd] + 1 - discSkipToFrame(q.currentFrame) - q.bSave = false - q.lvlState = QTE.lvlRunning - elseif (not q.bSkipIntroClip) and (q.iScene == 1) then - local flipBy = q.bFlip and level[QTE.MIRROR] or 0 - - Q.setupClip(q, level[QTE.INTROCLIP] + flipBy, level[QTE.INTROCLIPEND] + flipBy) - q.bShowSkip = true - q.lvlState = QTE.branch01 - else - if (q.currentFrame + 2 <= q.sceneStart) or (q.currentFrame > q.sceneStart) then - discSkipToFrame(q.sceneStart) - end - q.lvlState = QTE.lvlRunning - end -end - - --- Whether the next move ends the scene with a kind that rewind mode 3 counts the scene done on. -local function nextEndsScene(q) - local after = q.move[q.currentMove + 1] - - if q.currentMove == q.totalMoves then - return true - end - - return after and (q.currentMove + 1 == q.totalMoves) and ((after[3] == KIMMY.CHOOSE) or (after[3] == KIMMY.LETGO) or (after[3] == KIMMY.PATH) or (after[3] == KIMMY.YESNO)) -end - - --- Back into play after a death, from the frame the move paused on. -local function resumePlay(q) - local m = q.move[q.currentMove] - local after = q.move[q.currentMove + 1] - - K.resetArrows(q) - discSkipToFrame(q.iPauseFrame) - if q.dip_Display == 0 then - q.bShowScore, q.bShowLives, q.bShowLvl = true, true, true - q.bShowTop = q.ShowTop and true or false - else - q.bShowScore, q.bShowLives, q.bShowLvl, q.bShowTop = false, false, false, false - end - if (q.dip_Rewind == 3) and after and (after[3] == KIMMY.LETGO) then - q.bSwap = true - q.currentMove = q.currentMove + 2 - elseif (q.dip_Rewind == 3) and (m[3] == KIMMY.PATH) then - q.iPathAend = q.path[q.currentMove][4] - 1 - q.iPathAjmp = q.path[q.currentMove][9] - q.currentMove = q.currentMove + 1 - else - q.currentMove = q.currentMove + 1 - end - q.lvlState = QTE.lvlRunning -end - - --- After a death clip (or the extra clip after it): the death counted, the rewind mode says --- where play goes. getReady: whether the "get ready" clip may play first. -function K.afterDeath(q, thisLevel, getReady) - q.stage[thisLevel][QTE.DEATHCOUNT] = q.stage[thisLevel][QTE.DEATHCOUNT] + 1 - if q.iLives <= 0 then - q.lvlState = QTE.lvlEnd - return - end - if q.dip_Rewind == 0 then - q.bRes, q.bPath, q.bTime = true, true, true - q.lvlState = QTE.lvlEnd - elseif q.dip_Rewind == 2 then - q.bRes, q.bPath, q.bTime, q.bSwap = true, true, true, true - q.iScene = 0 - for i = 1, q.Level[thisLevel][QTE.TOTALSCENES] do - q.scene[thisLevel][i] = { i, false } - end - q.lvlState = QTE.lvlSetup - elseif (q.dip_Rewind == 3) and nextEndsScene(q) then - q.bSwap = true - q.scene[q.iLevel][q.iScene][QTE.SCENECOMPLETE] = true - q.lvlState = QTE.lvlEnd - elseif getReady and q.ShowResurrect and (q.dip_GameType ~= 3) and (q.i2P == 0) then - Q.setupClip(q, q.offsetGetReady, q.offsetGetReadyEnd) - q.bShowGet, q.bShowTop = true, false - if (q.dip_Display == 1) or ((q.dip_Display == 2) and getReady == "both") then - q.bShowScore, q.bShowLives, q.bShowLvl = true, true, true - else - q.bShowScore, q.bShowLives, q.bShowLvl, q.bShowTop = false, false, false, false - end - q.lvlState = QTE.branch09 - else - resumePlay(q) - end -end - - --- A scene completed: the last of the game's last level ends the game; any other counts its --- bonus and moves on. Answers whether the game ended. -function K.sceneDone(q, thisLevel) - local bonus = q.SCORESCENE - q.iScDeath * q.DEATHPENALTY - - q.scene[thisLevel][q.iScene][QTE.SCENECOMPLETE] = true - if ((thisLevel == q.finalstage) and Q.beatLevel(q, q.finalstage)) or ((thisLevel == QTE.levelSecret) and Q.beatLevel(q, QTE.levelSecret)) then - if thisLevel == q.finalstage then - q.stage[thisLevel][QTE.BEATSTATUS] = true - end - if q.dip_GameType ~= 2 then - if Q.beatGame(q) then - if bonus > 0 then - K.addPoints(q, bonus, 0) - end - K.addPoints(q, q.SCOREGAME, 0) - if Q.beatGameWithOneLife(q) then - K.addPoints(q, q.SCORESECRET, 0) - end - else - if bonus > 0 then - K.addPoints(q, bonus, 0) - end - K.addPoints(q, q.SCORELEVEL, 0) - end - if thisLevel == QTE.levelSecret then - discPause() - Q.timerON(q, 0.1) - else - Q.sound(q, "sndvictory") - discSkipToFrame(q.frameVictory) - discPause() - Q.timerON(q, 3) - end - q.lvlState = QTE.branch05 - else - q.lvlState = QTE.lvlEnd - end - return true - end - if bonus > 0 then - K.addPoints(q, bonus, 0) - q.iBonus = q.iBonus + bonus - end - q.iTotDeath = q.iTotDeath + q.iScDeath - q.iScPlayed = q.iScPlayed + 1 - q.iScDeath = 0 - - return false -end - - -- A wrong move, or a tilt: hints first if the dip says so, else the death. function K.failed(q) K.resetVar(q) @@ -9238,7 +11037,7 @@ function K.failed(q) discPause() q.lvlState = QTE.branch03 else - if q.iPath > KIMMY.OUT then + if q.iPath > HELD.OUT then q.iPath = 0 end q.iWrongMv = q.iWrongMv + 1 @@ -9252,370 +11051,10 @@ function K.failed(q) Q.sound(q, "sndwrong") q.lvlState = QTE.lvlPlayRest if q.iLifeBar <= 0 then - K.setupDeathClip(q) + Q.setupDeathClip(q) end else - K.setupDeathClip(q) - end - end -end - - -function K.doLevel(q) - local thisLevel = q.iLevel - local level = q.Level[thisLevel] - - if q.lvlState == QTE.lvlSetup then - q.bShuffleOrder, q.bPlayPrompt, q.bPlayRight = true, true, true - q.bShowLvl, q.bShowAction, q.bShowNext = false, false, false - q.bLvlJump, q.bScnJump = false, false - q.bPath, q.bTime, q.bCalc = true, true, true - if (q.dip_GameType ~= 1) or (q.iScene == 0) then - q.iTilt = 0 - q.bShowWarnTilt, q.bShowTilt = false, false - end - if q.hayate and (q.iScene == 0) then - q.dip_Rewind = 0 - end - K.resetVar(q) - showPlayFlags(q) - K.resetArrows(q) - if not q.bSave then - q.currentMove = 1 - end - K.setupLevel(q, thisLevel) - if level[QTE.INTROCLIPEND] - level[QTE.INTROCLIP] < 2 then - q.bSkipIntroClip = true - end - if q.ShowResurrect and q.bRes and (q.dip_GameType ~= 3) and (q.i2P == 0) then - q.bShowTop = false - Q.setupClip(q, q.offsetGetReady, q.offsetGetReadyEnd) - if (q.dip_Display == 1) or (q.dip_Display == 2) then - q.bShowScore, q.bShowLives, q.bShowLvl = true, true, true - else - q.bShowScore, q.bShowLives, q.bShowLvl, q.bShowTop = false, false, false, false - end - q.bShowGet = true - q.lvlState = QTE.branch08 - q.bRes = false - else - K.enterScene(q, thisLevel) - end - elseif q.lvlState == QTE.branch01 then - if (q.currentFrame >= q.iFrameEnd) or q.p1BUTTON1 or q.p1BUTTON2 or q.p1BUTTON3 or q.p1UP or q.p1DOWN or q.p1LEFT or q.p1RIGHT then - Q.clearInput(q) - q.bShowSkip = false - q.bSkipIntroClip = true - if q.currentFrame ~= q.iFrameEnd then - discSkipToFrame(q.sceneStart) - end - q.lvlState = QTE.lvlRunning - end - elseif q.lvlState == QTE.branch02 then - K.failed(q) - elseif q.lvlState == QTE.branch03 then - if Q.timerDue(q) then - q.iPath = 0 - q.iWrongMv = q.iWrongMv + 1 - q.iScDeath = q.iScDeath + 1 - K.setupDeathClip(q) - end - elseif q.lvlState == QTE.branch04 then - q.bShowAction, q.bShowNext, q.bTime = false, false, true - K.resetVar(q) - q.lvlState = QTE.lvlPlayRest - if (q.thisMove ~= KIMMY.NOTHING) or ((not q.hayate) and (q.thisMove == KIMMY.NOTHING) and (q.move[q.currentMove][5] == nil)) then - if q.bPlayRight then - Q.sound(q, "sndright") - q.bPlayRight = false - end - q.iRightMv = q.iRightMv + 1 - K.addPoints(q, q.SCOREMOVE + q.dip_Difficulty * q.BUFFMOVE, q.currentMove) - q.iScoreTemp = q.iScoreTemp + (q.SCOREMOVE + q.dip_Difficulty * q.BUFFMOVE) - end - elseif q.lvlState == QTE.branch05 then - if Q.timerDue(q) then - q.bGOAlt = true - if (Q.beatGameWithOneLife(q) or Q.beatGameWithOneCredit(q)) and (thisLevel ~= QTE.levelSecret) and q.AllowSecret and (q.dip_GameType ~= 4) then - Q.sound(q, "sndvictory") - discSkipToFrame(q.frameSecret) - discPause() - Q.timerON(q, 4) - q.lvlState = QTE.branch06 - elseif (q.dip_GameType ~= 4) and K.newScore(q, q.iScore) then - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelHighScore - elseif (q.dip_GameType == 4) and (not q.bUnlockSel) and K.newScore(q, q.iTotDeath + q.iScDeath) then - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelHighScore - else - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelGameOver - end - end - elseif q.lvlState == QTE.branch06 then - if Q.timerDue(q) then - q.bExtendedPlay = true - q.iScene = 0 - K.startGame(q) - end - elseif q.lvlState == QTE.branch07 then - if (q.currentFrame >= q.iFrameEnd) or q.p1BUTTON1 then - K.afterDeath(q, thisLevel, "both") - end - elseif q.lvlState == QTE.branch08 then - if q.currentFrame >= q.iFrameEnd then - q.bShowGet = false - showPlayFlags(q) - K.enterScene(q, thisLevel) - end - elseif q.lvlState == QTE.branch09 then - if q.currentFrame >= q.iFrameEnd then - q.bShowGet = false - resumePlay(q) - showPlayFlags(q) - end - elseif q.lvlState == QTE.branch10 then - Q.doChoose(q) - elseif q.lvlState == QTE.branch11 then - if q.currentFrame >= q.iFrameEnd then - q.lvlState = QTE.lvlSetup - end - elseif q.lvlState == QTE.branch12 then - if not K.sceneDone(q, thisLevel) then - q.lvlState = QTE.lvlEnd - end - elseif q.lvlState == KIMMY.branch13 then - if Q.timerDue(q) then - K.afterDeath(q, thisLevel, false) - else - q.bShowScore, q.bShowLives = true, true - end - elseif q.lvlState == QTE.lvlPlayRest then - local m = q.move[q.currentMove] - - q.bPlayRight = true - if q.currentMove < q.totalMoves then - q.bCheckMove = true - end - q.thisMove = Q.scanInput(q) - if q.currentFrame >= m[QTE.moveFrmEnd] then - q.bCheckMove = false - if q.iPathAjmp == KIMMY.OUT then - q.currentMove = q.iPathAjmp - q.iPathAend, q.iPathAjmp = 0, 0 - q.lvlState = QTE.branch12 - elseif (q.iPathAend ~= 0) and (q.currentMove == q.iPathAend) then - q.currentMove = q.iPathAjmp - q.iPathAend, q.iPathAjmp = 0, 0 - elseif (q.iPath ~= 0) and (q.currentMove <= q.totalMoves) then - q.currentMove = q.iPath - q.iPath = 0 - q.bPath = true - else - q.currentMove = q.currentMove + 1 - end - if q.currentMove <= q.totalMoves then - local next = q.move[q.currentMove] - - if (q.currentFrame + 2 <= next[QTE.moveFrmStart]) or (q.currentFrame > next[QTE.moveFrmStart]) then - discSkipToFrame(next[QTE.moveFrmStart]) - end - q.bShowAction = false - q.bPlayPrompt = true - K.resetArrows(q) - if next[QTE.correctMove] == KIMMY.CHOOSE then - q.altState = QTE.lvlSetup - q.lvlState = QTE.branch10 - else - q.lvlState = QTE.lvlRunning - end - else - q.bShowNext = false - if not K.sceneDone(q, thisLevel) then - if q.currentFrame >= q.sceneEnd then - q.lvlState = QTE.lvlEnd - end - end - end - end - elseif q.lvlState == QTE.lvlRunning then - local m = q.move[q.currentMove] - local kind = m[QTE.correctMove] - - if (q.dip_GameType <= 2) and q.dip_Next then - q.bShowNext = true - end - if q.currentFrame < m[QTE.inputFrmStart] then - q.thisMove = Q.scanInput(q) - if (q.thisMove ~= KIMMY.NOMOVE) and (kind < 25) and (q.dip_Tilt > 0) and (q.dip_GameType < 5) then - q.iTilt = q.iTilt + 1 - q.iTrigTilt = 50 - 10 * q.dip_Tilt - if q.iTilt >= q.iTrigTilt then - q.iPauseFrame = m[QTE.inputFrmEnd] - if (q.dip_GameType == 0) or (q.dip_GameType == 3) or (q.dip_GameType == 4) then - q.lvlState = QTE.branch02 - elseif q.dip_GameType == 1 then - q.iLifeBar = q.iLifeBar - q.BarMinT - q.iRightMv, q.iTilt = 0, 0 - Q.sound(q, "sndwrong") - q.altState = QTE.branch01 - if q.iLifeBar <= 0 then - K.setupDeathClip(q) - end - elseif q.dip_GameType == 2 then - q.iTiltMv = q.iTiltMv + 1 - q.iTilt = 0 - Q.sound(q, "sndwrong") - q.altState = QTE.branch01 - end - q.bShowTilt = true - elseif (q.iTilt > q.iTrigTilt / 2) and (q.iTilt < q.iTrigTilt) and (q.dip_GameType ~= 5) and (q.dip_GameType ~= 6) then - q.bShowWarnTilt = true - end - end - elseif (q.currentFrame >= m[QTE.inputFrmStart]) and (q.currentFrame <= m[QTE.inputFrmEnd]) then - q.bShowAction = true - if q.bPlayPrompt and ((q.dip_ShowAction == 1) or (q.dip_ShowAction == 5)) and (kind < 50) and not q.hayate then - Q.sound(q, "sndcoin") - q.bPlayPrompt = false - end - K.doMove(q) - elseif q.currentFrame > m[QTE.inputFrmEnd] then - local pending = (q.thisMove == KIMMY.NOMOVE) or (q.thisMove == KIMMY.MOVEPENDING) - local jump = (kind == KIMMY.SKIP) or (kind == KIMMY.WAY) or (kind == KIMMY.WAYOUT) or (kind == KIMMY.TOSCENE) or (kind == KIMMY.TOLEVEL) - - if pending and ((m[QTE.moveDeath] == -1) or (m[QTE.moveDeath] == -3)) then - q.bShowAction = false - q.lvlState = QTE.lvlPlayRest - elseif pending and (m[QTE.moveDeath] == -2) then - q.bShowAction = false - q.lvlState = QTE.branch04 - elseif not jump then - q.iPauseFrame = m[QTE.inputFrmEnd] - q.bShowAction = false - q.lvlState = QTE.branch02 - else - q.bShowAction = false - K.addPoints(q, 0, q.currentMove) - if (kind == KIMMY.WAY) and (m[QTE.moveDeath] > 0) then - q.iPath = m[QTE.moveDeath] - q.lvlState = QTE.lvlPlayRest - elseif kind == KIMMY.WAYOUT then - q.iPath = m[QTE.moveDeath] - q.lvlState = QTE.branch12 - elseif kind == KIMMY.TOSCENE then - q.bScnJump = true - q.iPath = m[QTE.moveDeath] - q.lvlState = QTE.branch12 - elseif kind == KIMMY.TOLEVEL then - q.bLvlJump = true - q.iPath = m[QTE.moveDeath] - q.lvlState = QTE.branch12 - else - q.lvlState = QTE.lvlPlayRest - end - end - else - K.resetVar(q) - end - elseif q.lvlState == QTE.lvlPlayDeath then - if q.dip_Display == 1 then - q.bShowScore, q.bShowLives = true, true - else - q.bShowScore, q.bShowLives = false, false - end - if q.currentFrame >= q.iFrameEnd then - q.iTilt = 0 - q.bShowWarnTilt, q.bShowTilt = false, false - if q.i2P > 0 then - q.i2P = (q.i2P == 1) and 2 or 1 - q.lvlState = QTE.lvlSetup - q.currentLevel = (q.b1PEnd and q.b2PEnd) and KIMMY.level2PEnd or KIMMY.level2P - elseif (q.dip_Display == 2) and (q.iLives > 0) and (not q.ShowSupDeath) and (not q.ShowResurrect) then - discSkipToFrame(q.frameHints) - discPause() - Q.timerON(q, 3) - q.lvlState = KIMMY.branch13 - elseif q.ShowSupDeath and (q.dip_GameType ~= 3) then - Q.setupClip(q, q.offsetSupDeath, q.offsetSupDeathEnd) - q.lvlState = QTE.branch07 - else - K.afterDeath(q, thisLevel, true) - end - end - elseif q.lvlState == QTE.lvlEnd then - q.lvlState = QTE.lvlSetup - q.bPath, q.bTime = true, true - if q.iLives == 0 then - if (q.dip_GameType ~= 3) and (q.dip_LimitContinue > 0) and ((q.iContinues < q.dip_LimitContinue) or (q.dip_LimitContinue == QTE.DOPT_INFINITE_CONTINUES)) then - q.iTempLevel = q.currentLevel - q.currentLevel = QTE.levelContinue - q.iContinues = q.iContinues + 1 - else - q.dip_StartLevel, q.dip_StartScene = 1, 1 - q.currentLevel = K.newScore(q, q.iScore) and QTE.levelHighScore or QTE.levelGameOver - end - else - if Q.beatLevel(q, thisLevel) and not q.bScnJump then - q.stage[thisLevel][QTE.BEATSTATUS] = true - q.iScene = 0 - K.addPoints(q, q.SCORELEVEL, 0) - q.iBonus = q.iBonus + q.SCORELEVEL - if ((q.dip_GameType == 1) and (q.iTotDeath == 0)) or ((q.dip_GameType ~= 1) and (q.stage[thisLevel][QTE.DEATHCOUNT] == 0)) then - K.addPoints(q, q.PERFECTBONUS, 0) - q.iBonus = q.iBonus + q.PERFECTBONUS - end - q.bSkipIntroClip = false - q.iLiveSave, q.iScoreSave = q.iLives, q.iScore - q.bAllowSave, q.bRes = true, true - if (q.ShowLvlClear or (q.dip_GameType == 2)) and (q.dip_GameType ~= 3) and (q.dip_GameType ~= 4) then - Q.sound(q, "sndclear") - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelFinish - else - q.iScoreTemp, q.iBonus = 0, 0 - if q.dip_PlayStyle == 3 then - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelMap - elseif q.dip_PlayStyle == 4 then - q.dip_StartLevel = q.dip_StartLevel + 1 - q.iScene = 0 - q.altState = QTE.lvlSetup - q.currentLevel = KIMMY.levelSelect - else - K.nextLevel(q, q.iLevel) - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelNormal - end - end - else - if (q.iPath ~= 0) and not q.bLvlJump then - q.iScene = q.iPath - 1 - end - if (not q.scene[thisLevel][q.iScene][QTE.SCENECOMPLETE]) and (q.iPath == 0) then - if q.dip_Rewind == 0 then - q.bSwap = true - end - if q.iScene > 0 then - q.iScene = q.iScene - 1 - end - else - q.iLiveSave, q.iScoreSave = q.iLives, q.iScore - q.bAllowSave, q.bSave = true, false - if q.bLvlJump then - K.nextLevel(q, q.iLevel) - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelNormal - end - end - q.iPath = 0 - if (q.dip_Rewind == 0) or (q.dip_Rewind == 2) then - K.levelReplay(q) - end - end - q.bShowLives, q.bShowLvl, q.bShowAction, q.bShowNext = false, false, false, false - K.resetVar(q) + Q.setupDeathClip(q) end end end @@ -9623,172 +11062,6 @@ end -- ----- The screens --------------------------------------------------------------------------- --- The life bar's costs and gains for the difficulty, and the lives a game type starts with. -local function livesForType(q) - if q.dip_GameType == 1 then - q.iLives = 1 - if q.dip_Difficulty == 0 then - q.BarMinT = q.BarMin - if q.BarBonus > 0 then - q.BarBonusT = q.BarBonus - 1 - end - elseif q.dip_Difficulty == 1 then - q.BarMinT, q.BarBonusT = q.BarMin, q.BarBonus - elseif q.dip_Difficulty == 2 then - q.BarMinT, q.BarBonusT = q.BarMin + 1, q.BarBonus - else - q.BarMinT, q.BarBonusT = q.BarMin + 1, q.BarBonus + 1 - end - elseif q.dip_GameType == 3 then - q.iLives = 1 - else - q.iLives = q.dip_LivesPerCredit - end -end - - --- The record the game type plays against. -local function topFor(q) - if q.dip_GameType == 1 then - return q.iTopLB - elseif q.dip_GameType == 3 then - return q.iTopS - elseif q.dip_GameType == 4 then - return q.hsDR[q.dip_Difficulty + 1] - end - - return q.iTopN -end - - -local function toLevelSelect(q) - q.altState = QTE.lvlSetup - q.currentLevel = KIMMY.levelSelect -end - - -function K.startGame(q) - math.randomseed(KARIS_SEED or os.time()) - math.random(100) - q.bLvlJump = false - if q.bExtendedPlay then - Q.initStages(q) - q.currentLevel = QTE.levelNormal - q.iLevel = QTE.levelSecret - else - if (q.iCredits > 0) and (q.i2P == 0) then - q.iCredits = q.iCredits - 1 - end - q.iScore, q.iScoreTemp, q.iBonus, q.iScPlayed, q.iScDeath, q.iTotDeath = 0, 0, 0, 0, 0, 0 - q.iRightMv, q.iWrongMv, q.iTiltMv, q.numTrophy = 0, 0, 0, 0 - if q.currentLevel == QTE.levelContinue then - q.currentLevel = q.iTempLevel - q.iLifeBar = q.BarSize - if q.dip_Rewind == 0 then - q.bSwap = true - K.levelReplay(q) - elseif q.dip_Rewind == 1 then - q.currentMove = q.currentMove + 1 - q.bSave = true - discSkipToFrame(q.iPauseFrame) - q.lvlState = QTE.lvlRunning - elseif q.dip_Rewind == 2 then - q.bRes, q.bPath, q.bTime, q.bSwap = true, true, true, true - q.iScene = 0 - K.levelReplay(q) - elseif q.dip_Rewind == 3 then - if q.currentMove == q.totalMoves then - q.bSwap = true - q.scene[q.iLevel][q.iScene][QTE.SCENECOMPLETE] = true - q.iScene, q.currentMove, q.bSave = q.iScene + 1, 1, true - elseif nextEndsScene(q) then - q.bSwap = true - q.scene[q.iLevel][q.iScene][QTE.SCENECOMPLETE] = true - q.iScene, q.currentMove = q.iScene + 1, 1 - else - q.currentMove, q.bSave = q.currentMove + 1, true - end - end - else - Q.initStages(q) - q.bSkipIntroClip = false - q.iPath, q.iPathAend, q.iPathAjmp = 0, 0, 0 - q.iContinues, q.iScene, q.currentMove = 0, 0, 1 - if (q.dip_GameType == 0) or (q.dip_GameType == 1) or (q.dip_GameType == 4) then - q.iLifeBar = q.BarSize - if q.dip_GameType == 4 then - q.dip_PlayStyle = 0 - if not q.bUnlockSel then - q.dip_StartLevel, q.dip_StartScene = 1, 1 - end - end - q.iTop = topFor(q) - if q.dip_PlayStyle == 0 then - Q.doMixSEQ(q) - q.iLevel = q.dip_StartLevel - q.iScene = q.dip_StartScene - 1 - q.currentLevel = QTE.levelNormal - elseif q.dip_PlayStyle == 1 then - Q.doMixRND(q) - q.iLevel = q.LvlOrder[1] - q.currentLevel = QTE.levelNormal - elseif q.dip_PlayStyle == 2 then - Q.doMixTIE(q) - q.iLevel = q.LvlOrder[1] - q.currentLevel = QTE.levelNormal - elseif q.dip_PlayStyle == 3 then - q.iLevel = q.PlayOrder[1] - if q.MapStart == 1 then - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelNormal - else - q.currentLevel = QTE.levelMap - end - elseif q.dip_PlayStyle == 4 then - q.iLevel, q.iScene = q.dip_StartLevel, 0 - toLevelSelect(q) - end - elseif q.dip_GameType == 2 then - q.bShowLives, q.bShowScore = false, false - q.iLevel, q.iScene = q.dip_StartLevel, 0 - if not q.bNoScreen then - toLevelSelect(q) - else - q.currentLevel = QTE.levelNormal - end - elseif q.dip_GameType == 3 then - Q.doMixSEQ(q) - q.iLevel, q.iScene = 1, 0 - q.iTop = q.iTopS - q.currentLevel = QTE.levelNormal - elseif q.dip_GameType == 5 then - q.iLevel, q.iScene = q.dip_StartLevel, 0 - toLevelSelect(q) - end - if q.startConf then - q.startConf() - end - end - end - if q.IngameDiffchoice and (q.dip_Diffshow == 4) and q.bOneDiff and not q.bNoScreen then - q.altState = QTE.lvlSetup - q.currentLevel = QTE.levelDiffScreen - if q.i2P > 0 then - q.bOneDiff = false - end - end - if (q.offsetIntroGame ~= 0) and (q.iContinues == 0) and (q.dip_StartScene == 1) then - Q.setupClip(q, q.offsetIntroGame, q.offsetIntroGameend) - q.lvlState = QTE.branch11 - else - q.lvlState = QTE.lvlSetup - end - q.bShowScore, q.bRes = false, true - livesForType(q) - q.bShowAction, q.bShowNext, q.bShowCredits, q.bShowLCD, q.bResetContinue, q.bExtendedPlay = false, false, false, false, false, false -end - - -- The attract loop: the clips and stills in turn, the secret combination, and a start. function K.doIntro(q) local function still(frame, seconds, nextState) @@ -9809,7 +11082,7 @@ function K.doIntro(q) if Q.secretPressed(q) then q.p1BUTTON2, q.p1BUTTON3, q.p1BUTTON4, q.p1UP, q.p1RIGHT = false, false, false, false, false q.bExtendedPlay = true - K.startGame(q) + q.qteFns.startGame(q) q.bShowCredits = false return true end @@ -9881,20 +11154,20 @@ function K.doIntro(q) if free or (q.iCredits > 0) then if q.p1START1 then q.p1START1, q.bShowCredits, q.i2P = false, false, 0 - if q.hayate and q.ArcadeMode then + if q.arcadeEdition and q.ArcadeMode then q.dip_StartLevel, q.dip_StartScene = 1, 1 end if (q.dip_GameType == 2) or (q.dip_GameType == 3) then - K.startGame(q) + q.qteFns.startGame(q) elseif q.dip_GameType == 6 then q.lvlState = QTE.lvlSetup q.currentLevel = QTE.levelMovie elseif free and (q.dip_PlayStyle ~= 4) then q.lvlState = QTE.lvlSetup - q.currentLevel = KIMMY.levelNG + q.currentLevel = HELD.levelNG else q.lvlState = QTE.lvlSetup - K.startGame(q) + q.qteFns.startGame(q) end end end @@ -9909,7 +11182,7 @@ function K.doIntro(q) q.b1PStart, q.b2PStart, q.b1PEnd, q.b2PEnd = false, false, false, false q.i1PScore, q.i2PScore = 0, 0 q.bShowCredits = false - q.currentLevel = KIMMY.level2P + q.currentLevel = HELD.level2P end end end @@ -9918,85 +11191,11 @@ end -- The saves the game shipped with, as the description snapshots them: the framework reads -- its six slots from Cfg/s<n>.cfg, a line of eleven fields, then a line per level. local function slotOf(q, slot) - return (q.snapshot.saves or {})[slot] + return q.saves[slot] end -- The autosave, resumed: the framework's loadSave, without the writing. -function K.loadSave(q, thisSlot) - local save = slotOf(q, thisSlot) - - if save == nil then - q.lvlState = QTE.lvlEnd - return - end - local line = save.line - - q.scene, q.stage = {}, {} - for k = 1, q.finalstage do - q.scene[k] = {} - q.stage[k] = { false, false, 0 } - for i = 1, q.Level[k][QTE.TOTALSCENES] do - q.scene[k][i] = { i, false } - end - end - if (thisSlot == 5) or (thisSlot == 6) then - q.iLevel, q.iScene = tonumber(line[1]), tonumber(line[2]) - else - q.dip_StartLevel, q.dip_StartScene = tonumber(line[1]), tonumber(line[2]) - end - q.currentMove = tonumber(line[3]) - q.dip_GameType = tonumber(line[7]) - if q.dip_GameType == 1 then - q.iLifeBar, q.iLives = tonumber(line[4]), 1 - else - q.iLives = tonumber(line[4]) - end - q.iScPlayed, q.iScDeath, q.iTotDeath = tonumber(line[8]), tonumber(line[9]), tonumber(line[10]) - q.dip_PlayStyle, q.dip_Difficulty, q.iScore = tonumber(line[11]), tonumber(line[5]), tonumber(line[6]) - for k, entry in ipairs(save.levels) do - q.LvlOrder[k] = entry[1] - if entry[2] then - q.stage[k][QTE.LEVELSTARTED] = true - end - if entry[3] then - q.stage[k][QTE.BEATSTATUS] = true - end - q.stage[k][QTE.DEATHCOUNT] = entry[4] - end - for k = 1, q.finalstage do - if q.stage[k][QTE.BEATSTATUS] then - for i = 1, q.Level[k][QTE.TOTALSCENES] do - q.scene[k][i] = { i, true } - end - elseif q.stage[k][QTE.LEVELSTARTED] and (q.dip_StartScene ~= 1) then - for i = 1, q.dip_StartScene - 1 do - q.scene[k][i] = { i, true } - end - end - end - if q.AllowSecret and q.Level[QTE.levelSecret] then - q.stage[QTE.levelSecret] = { false, false, 0 } - q.scene[QTE.levelSecret] = {} - for i = 1, q.Level[QTE.levelSecret][QTE.TOTALSCENES] do - q.scene[QTE.levelSecret][i] = { i, false } - end - end - if (q.dip_StartLevel == 1) and (q.dip_StartScene == 1) and (q.currentMove == 1) and (thisSlot < 5) then - q.lvlState = QTE.lvlEnd - elseif (q.dip_StartScene <= 0) and (q.dip_PlayStyle == 3) then - q.iLiveSave, q.iScoreSave = q.iLives, q.iScore - K.startMap(q) - else - q.iLiveSave, q.iScoreSave = q.iLives, q.iScore - K.startSave(q) - end - if q.loadSavePlus then - q.loadSavePlus(thisSlot) - end -end - - function K.startSave(q) if q.currentMove ~= 1 then q.bSave = true @@ -10010,9 +11209,9 @@ function K.startSave(q) end q.iScoreTemp = q.iScore if q.dip_GameType == 1 then - livesForType(q) + Q.livesForType(q) end - q.iTop = topFor(q) + q.iTop = Q.topFor(q) q.currentLevel = QTE.levelNormal q.lvlState = QTE.lvlSetup if q.dip_Display == 0 then @@ -10032,11 +11231,11 @@ function K.startMap(q) q.iScoreTemp = q.iScore q.iScPlayed, q.iScDeath, q.iTotDeath = 0, 0, 0 if q.dip_GameType == 1 then - livesForType(q) + Q.livesForType(q) else q.iLives = q.dip_LivesPerCredit end - q.iTop = topFor(q) + q.iTop = Q.topFor(q) q.currentLevel = QTE.levelMap q.lvlState = QTE.lvlSetup q.bShowScore, q.bShowLives, q.bShowLvl, q.bShowTop, q.bShowAction, q.bShowNext = false, false, false, false, false, false @@ -10061,17 +11260,17 @@ end function K.updateNG(q, index) if index == 2 then q.dip_StartLevel, q.dip_StartScene = 1, 1 - K.startGame(q) + q.qteFns.startGame(q) elseif index == 3 then if autosaveIsFresh(q) then Q.sound(q, "sndwrong") else - K.loadSave(q, 4) + Q.loadSave(q, 4) end elseif index == 4 then q.gameflow = "init" elseif index == 10 then - K.startGame(q) + q.qteFns.startGame(q) end end @@ -10081,7 +11280,7 @@ function K.doNG(q) if q.lvlState == QTE.lvlSetup then if autosaveIsFresh(q) then - K.startGame(q) + q.qteFns.startGame(q) else discSkipToFrame(q.frameNewGame) discPause() @@ -10169,10 +11368,10 @@ function K.moveFrameLevel(q) end local level = q.Level[q.dip_StartLevel] - if level[8] ~= nil then - discSkipToFrame(q.RelativeFrames and (level[2] + level[8]) or level[8]) + if level[QTE.SELECTSTILL] ~= nil then + discSkipToFrame(q.RelativeFrames and (level[QTE.INTROCLIP] + level[QTE.SELECTSTILL]) or level[QTE.SELECTSTILL]) else - discSkipToFrame(level[2] + 100) + discSkipToFrame(level[QTE.INTROCLIP] + 100) end discPause() end @@ -10257,10 +11456,10 @@ function K.doClear(q) elseif q.dip_PlayStyle == 4 then q.dip_StartLevel = q.dip_StartLevel + 1 q.iScene = 0 - toLevelSelect(q) + Q.toLevelSelect(q) else q.iWrongMv = 0 - K.nextLevel(q, q.iLevel) + Q.nextLevel(q, q.iLevel) q.lvlState = QTE.lvlSetup q.currentLevel = QTE.levelNormal end @@ -10307,7 +11506,7 @@ function K.doFinish(q) q.iScoreTemp, q.iBonus, q.numTrophy = 0, 0, 0 Q.sound(q, "sndcredit") if restart then - K.startGame(q) + q.qteFns.startGame(q) end end end @@ -10315,150 +11514,6 @@ function K.doFinish(q) end -function K.doContinue(q) - if q.lvlState == QTE.lvlSetup then - Q.setupClip(q, q.offsetContinue, q.offsetContinueend) - q.bShowLives, q.bShowLvl, q.bShowScore, q.bShowAction, q.bShowNext = false, false, false, false, false - q.bShowCredits = true - q.iTilt = 0 - q.bShowWarnTilt, q.bShowTilt = false, false - q.lvlState = QTE.lvlRunning - elseif q.lvlState == QTE.lvlRunning then - if q.currentFrame >= q.iFrameEnd then - q.lvlState = QTE.lvlEnd - elseif q.p1START1 then - q.p1START1 = false - if (q.iCredits > 0) or (q.dip_CoinsPerCredit == QTE.DOPT_FREEPLAY) then - q.bOneDiff, q.bSkipIntroClip = false, true - if q.iScene > 0 then - if q.hayate then - Q.initStages(q) - q.iScene, q.currentMove = 0, 1 - else - q.iScene = q.iScene - 1 - end - end - K.startGame(q) - end - elseif q.p1BUTTON2 then - q.p1BUTTON2 = false - q.lvlState = QTE.lvlEnd - end - elseif q.lvlState == QTE.lvlEnd then - q.lvlState = QTE.lvlSetup - if K.newScore(q, q.iScore) then - q.currentLevel = QTE.levelHighScore - q.bGOAlt = true - else - q.currentLevel = QTE.levelGameOver - end - end -end - - -function K.doGameOver(q) - if q.lvlState == QTE.lvlSetup then - q.bShowLives, q.bShowLvl, q.bShowScore, q.bShowCredits, q.bShowAction, q.bShowNext = false, false, false, false, false, false - q.bOneDiff = true - q.iTilt = 0 - q.bShowWarnTilt, q.bShowTilt = false, false - if q.bGOAlt then - Q.setupClip(q, q.offsetGameOverAlt, q.offsetGameOverAltend) - q.bGOAlt = false - else - Q.setupClip(q, q.offsetGameOver, q.offsetGameOverend) - end - q.lvlState = QTE.lvlRunning - elseif q.lvlState == QTE.lvlRunning then - if q.currentFrame >= q.iFrameEnd then - q.bShowScore = false - q.lvlState = QTE.lvlEnd - end - elseif q.lvlState == QTE.lvlEnd then - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelIntro - end -end - - --- The high score board: its clips, the name left unentered (what is typed is not play), the --- rankings, and on to the game over -- or the intro, for the percent game. -function K.doHighScore(q) - if q.lvlState == QTE.lvlSetup then - q.bShowLives, q.bShowScore, q.bShowCredits, q.bShowAction, q.bShowNext, q.bShowLCD = false, false, false, false, false, false - q.bIgnoreJoy = false - q.iTilt = 0 - q.bShowWarnTilt, q.bShowTilt = false, false - Q.setupClip(q, q.offsetNewHScore, q.offsetNewHScoreend) - q.lvlState = QTE.branch01 - elseif q.lvlState == QTE.branch01 then - if (q.currentFrame >= q.iFrameEnd) or q.p1BUTTON1 then - q.p1BUTTON1 = false - if q.iFrameEnd + 1 ~= q.offsetEnterHScore then - Q.setupClip(q, q.offsetEnterHScore, q.offsetEnterHScoreend) - else - q.iFrameEnd = q.offsetEnterHScoreend - end - q.lvlState = QTE.lvlRunning - end - elseif q.lvlState == QTE.lvlRunning then - if q.currentFrame >= q.iFrameEnd then - Q.setupClip(q, q.offsetRankings, q.offsetRankingsend) - q.lvlState = ((q.dip_GameType == 2) or (q.dip_GameType == 4)) and QTE.branch03 or QTE.branch02 - end - elseif (q.lvlState == QTE.branch02) or (q.lvlState == QTE.branch03) then - if q.currentFrame >= q.iFrameEnd then - q.lvlState = QTE.lvlEnd - end - elseif q.lvlState == QTE.lvlEnd then - q.lvlState = QTE.lvlSetup - if q.dip_GameType == 2 then - q.currentLevel = QTE.levelIntro - else - q.bGOAlt = true - q.currentLevel = QTE.levelGameOver - end - end -end - - -function K.doDiffSelect(q) - if q.altState == QTE.lvlSetup then - q.bShowScore, q.bShowLives, q.bIgnoreJoy = false, false, false - q.dip_Difficulty = 0 - discSkipToFrame(q.frameEasy) - discPause() - Q.timerON(q, 30) - q.altState = QTE.lvlRunning - elseif q.altState == QTE.lvlRunning then - if Q.timerDue(q) then - q.altState = QTE.lvlEnd - elseif q.p1BUTTON1 then - Q.sound(q, "sndcredit") - q.p1BUTTON1 = false - q.altState = QTE.lvlEnd - else - Q.moveFrameDiff(q) - end - elseif q.altState == QTE.lvlEnd then - livesForType(q) - if (q.offsetIntroGame ~= 0) and (q.dip_StartScene == 1) then - Q.setupClip(q, q.offsetIntroGame, q.offsetIntroGameend) - q.lvlState = QTE.branch11 - else - q.lvlState = QTE.lvlSetup - end - if (q.dip_PlayStyle == 3) and (q.MapStart == 0) then - q.currentLevel = QTE.levelMap - elseif (q.dip_GameType == 2) or (q.dip_GameType == 4) or (q.dip_PlayStyle == 4) then - toLevelSelect(q) - else - q.currentLevel = QTE.levelNormal - end - end -end - - -- The quit menu, reached by START with COIN held: QUIT ends the game as the original does; -- the other rows go back to play or to the attract loop (the service menus are not played). function K.doExit(q) @@ -10521,7 +11576,7 @@ function K.doExit(q) elseif q.lvlState == QTE.lvlEnd then if q.bInPlayExit then q.bInPlayExit = false - K.loadSave(q, 4) + Q.loadSave(q, 4) else q.gameflow = "init" end @@ -10545,39 +11600,55 @@ function K.exitPlay(q) end -AUTHOR.behaviours.kimmy = { - help = "Plays a laserdisc quick-time-event game the Kimmy Script Engine way (Karis's 2024 framework), from the framework's own tables under game.qte: the attract loop, a new-game menu, levels of scenes with moves judged by what is held -- combos, mashes, holds, sequences, paths, timed and yes/no branches -- tilt, the life bar, the die-and-retry count, and the game's own add-ons. Written by util/forgePortKaris.lua from a game's script.", +QTE_STYLES.held = { + help = "Plays a laserdisc quick-time-event game whose moves are judged by what is held, from the tables under game.qte: the attract loop, a new-game menu, levels of scenes with moves judged by what is held -- combos, mashes, holds, sequences, paths, timed and yes/no branches -- tilt, the life bar, the die-and-retry count, and the game's own add-ons. The port converter writes the tables from an existing game's script.", params = {}, attach = function(instance, params) local data = AUTHOR_GAME.qte or {} - local q = { instance = instance, snapshot = data, kimmy = true } + local q = { instance = instance, snapshot = data, qteStyle = "held" } instance.q = q instance.hearsAll = true - -- The framework's constants -- Kimmy's over the older ones where the numbers differ -- + -- The constants -- the later generation's over the older ones where the numbers differ -- -- then the description's snapshot of the game's settings, under the game's own names. for key, value in pairs(QTE) do if (key ~= "FLAG_OF") and (key ~= "BRANCH") then q[key] = value end end - for key, value in pairs(KIMMY) do + for key, value in pairs(HELD) do q[key] = value end - for key, value in pairs(data.settings or {}) do - q[key] = value - end - for key, value in pairs(data.dips or {}) do - q[key] = value - end - for _, name in ipairs({ "dip_Hints", "ShowTop", "ShowLCD", "ShowLevel" }) do + Q.applySnapshot(q, data) + for _, name in ipairs(QTE_CONFIG_FLAGS) do q[name] = (q[name] == 1) or (q[name] == true) end - q.addPoints = K.addPoints + q.qteFns = HELD_FNS + -- How the held style plays where the shared screens differ; a description's qte.options + -- overrides any of them. + q.optionDefaults = { + barBonusRestarts = true, -- The count to the life bar's bonus restarts, bar full. + deathCountGameType = 4, -- Game type 4 counts deaths: no life lost, no points, + -- fewest ranked. + extraDeathClipSkips = true, -- BUTTON1 cuts the extra death clip short. + gameOverAfterContinue = "seek", -- The game over is always sought after a continue. + hintedDeathCounts = true, -- A death after the hints counts as the wrong move. + keepsSaves = false, -- Not yet: the held framework's slots (4 to 6) are unported. + levelSelect = true, -- Game types 2 and 4, and play style 4, choose a level. + managedHud = true, -- The loop sets the HUD by the display setting. + nameEntry = false, -- Not yet checked against the held framework's. + noGameOverGameType = 2, -- Game type 2 goes from the board to the attract. + outEndsSceneNow = true, -- The out jump ends the scene in the same step. + pendingIsUnanswered = true, -- A move pending as its window closes counts as none. + rankingsAfterBoard = "seek", -- The rankings are always sought after the board. + saveIcon = true, -- A disk shows where the game would save. + waitForWindowStart = true, -- A window that ends before it starts waits for it. + wayOutPlaysTarget = true -- A way out plays the scene it names, done or not. + } q.env = Q.shim(q) - -- Ninja Hayate 1080 carries a later copy of the engine (an LED panel, an arcade mode) that - -- differs from the others in a few things a player can see; its settings say which. - q.hayate = (q.ArcadeMode ~= nil) + -- A game on the later copy of the engine (an LED panel, an arcade mode) differs from the + -- others in a few things a player can see; its settings say which. + q.arcadeEdition = (q.ArcadeMode ~= nil) q.MYDIR = (AUTHOR_SOURCE_DIR or AUTHOR_DIR or ""):gsub("[/\\]$", "") for _, name in ipairs({ "Level", "Death", "Tiers", "PlayOrder", "LvlOrder", "LvlMap", "move", "choice", "path", "timed", "multi", "stage", "scene", "sprite", "sprArrow", "sprNUM", "Group1", "Group2", "Group3", "Group4" }) do q[name] = q[name] or {} @@ -10593,22 +11664,12 @@ AUTHOR.behaviours.kimmy = { q.sounds["snd" .. name] = "Sounds/" .. name .. ".wav" q["snd" .. name] = "snd" .. name end - q.highScores = data.highScores or {} - q.hsDR = data.hsDR or { 100, 100, 100, 100 } - q.iTopN = q.highScores[1] and q.highScores[1][2] or 0 - q.iTopLB, q.iTopS, q.iTop = q.iTopN, q.iTopN, q.iTopN - if discGetWidth and (discGetWidth() > 0) then - if (q.dip_Res or 0) == 0 then - overlaySetResolution(discGetWidth(), discGetHeight()) - else - overlaySetResolution(discGetWidth() / 2, discGetHeight() / 2) - end - end - local extra = q.dip_Extravid or 0 - - q.ShowResurrect = (extra == 1) or (extra == 4) or (extra == 5) or (extra == 7) - q.ShowSupDeath = (extra == 2) or (extra == 4) or (extra == 6) or (extra == 7) - q.ShowLvlClear = (extra == 3) or (extra == 5) or (extra == 6) or (extra == 7) + Q.boardsLoad(q, data) + Q.savesLoad(q) + q.hsDR = data.hsDR or { 100, 100, 100, 100 } + q.iTop = q.iTopN + Q.applyResolution(q) + Q.applyExtras(q) q.iPenal = ({ [0] = 0, q.PenalNormal or 0, q.PenalHard or 0, q.PenalExtreme or 0 })[q.dip_Difficulty or 1] or 0 q.BarMinT, q.BarBonusT = q.BarMin or 2, q.BarBonus or 3 -- initJob's defaults for the frames a game may not name. @@ -10623,7 +11684,7 @@ AUTHOR.behaviours.kimmy = { q.iMash, q.iMulti, q.iLenHold, q.lastHold, q.lenCounter, q.mashCounter, q.unMash = 0, 1, 0, 0, 8, 5, 0 q.iRightMv, q.iWrongMv, q.iTiltMv, q.iScPlayed, q.iScDeath, q.iTotDeath, q.iLifeBar, q.iPauseFrame, q.iTempLevel, q.numTrophy = 0, 0, 0, 1, 0, 0, q.BarSize or 10, 0, 0, 0 q.iTilt, q.iTrigTilt, q.curDeath, q.optSel, q.offsetFlip, q.Tlimit, q.i1PScore, q.i2PScore = 0, 50 - 10 * (q.dip_Tilt or 0), 0, 1, 0, 0, 0, 0 - q.thisMove, q.currentFrame, q.iFrameStart, q.iFrameEnd, q.thisScore, q.Hit = KIMMY.NOMOVE, 0, 0, 0, 0, 0 + q.thisMove, q.currentFrame, q.iFrameStart, q.iFrameEnd, q.thisScore, q.Hit = HELD.NOMOVE, 0, 0, 0, 0, 0 q.bOneDiff, q.bRes, q.bPath, q.bTime, q.bCalc = true, false, false, true, true q.bSave, q.bSwap, q.bGOAlt, q.bShowCredits, q.bIgnoreJoy, q.bAllowSave, q.bExtendedPlay = false, false, false, true, false, false, false q.bShowNext, q.bNoScreen, q.bUnlockSel, q.bLvlJump, q.bScnJump, q.bInPlayExit, q.bPause, q.bCheckMove = false, false, false, false, false, false, false, false @@ -10676,7 +11737,7 @@ AUTHOR.behaviours.kimmy = { if level == QTE.levelIntro then K.doIntro(q) elseif level == QTE.levelNormal then - K.doLevel(q) + Q.doLevel(q) elseif level == QTE.levelMap then if q.doLevelSelect then q.doLevelSelect() @@ -10685,42 +11746,34 @@ AUTHOR.behaviours.kimmy = { q.lvlState = QTE.lvlSetup end elseif level == QTE.levelDiffScreen then - K.doDiffSelect(q) - elseif level == KIMMY.levelSelect then + Q.doDiffSelect(q) + elseif level == HELD.levelSelect then K.doLvlSelect(q) elseif level == QTE.levelContinue then - K.doContinue(q) + Q.doContinue(q) elseif level == QTE.levelGameOver then - K.doGameOver(q) + Q.doGameOver(q) elseif level == QTE.levelHighScore then - K.doHighScore(q) + Q.doHighScore(q) elseif level == QTE.levelFinish then if q.dip_GameType == 2 then K.doFinish(q) else K.doClear(q) end - elseif level == KIMMY.levelNG then + elseif level == HELD.levelNG then K.doNG(q) elseif level == QTE.levelExit then K.doExit(q) - elseif (level == QTE.levelMovie) or (level == KIMMY.level2P) or (level == KIMMY.level2PEnd) then + elseif (level == QTE.levelMovie) or (level == HELD.level2P) or (level == HELD.level2PEnd) then -- The movie player and the two-player screens are not played: back to the attract loop. q.lvlState = QTE.lvlSetup q.currentLevel = QTE.levelIntro end - AUTHOR_VARS.score = q.iScore - AUTHOR_VARS.lives = q.iLives - AUTHOR_VARS.credits = q.iCredits - AUTHOR_VARS.level = q.iLevel + Q.publishVars(q) AUTHOR_VARS.deaths = q.iTotDeath + q.iScDeath AUTHOR_VARS.lifeBar = q.iLifeBar AUTHOR_VARS.tilt = q.iTilt - if q.bShowAction and q.move[q.currentMove] then - AUTHOR_VARS.prompt = q.move[q.currentMove][QTE.correctMove] - else - AUTHOR_VARS.prompt = "" - end end, on = function(instance, name, event) local q = instance.q @@ -10739,7 +11792,7 @@ AUTHOR.behaviours.kimmy = { return end q[flag] = true - local bit = KIMMY_BIT[flag] + local bit = HELD_BIT[flag] if bit then if q.bTestCombo then @@ -10767,7 +11820,7 @@ AUTHOR.behaviours.kimmy = { elseif (name == "released") and (q.gameflow == "running") and not q.bPause then if (flag == "p1COIN1") or (flag == "p1COIN2") then q.p1COIN1, q.p1COIN2 = false, false - if (q.currentLevel ~= QTE.levelService) and (q.currentLevel ~= QTE.levelNormal) and (q.dip_CoinsPerCredit ~= QTE.DOPT_FREEPLAY) and (q.iCredits < (q.hayate and 99 or 9)) then + if (q.currentLevel ~= QTE.levelService) and (q.currentLevel ~= QTE.levelNormal) and (q.dip_CoinsPerCredit ~= QTE.DOPT_FREEPLAY) and (q.iCredits < (q.arcadeEdition and 99 or 9)) then q.iCoins = q.iCoins + 1 if q.iCoins >= q.dip_CoinsPerCredit then q.iCoins = q.iCoins - q.dip_CoinsPerCredit @@ -10782,7 +11835,7 @@ AUTHOR.behaviours.kimmy = { end else q[flag] = false - local bit = KIMMY_BIT[flag] + local bit = HELD_BIT[flag] if bit and q.bTestCombo then q.acombo[bit] = 0 @@ -10793,9 +11846,9 @@ AUTHOR.behaviours.kimmy = { } --- ===== RDG's map-mode game loop ============================================================= +-- ===== The segmented quick-time-event loop ==================================================== -- --- The rdg behaviour plays a game of RDG's "LUA SINGE 1.0/1.1" lineage, the ancestor of the two +-- The segments style plays a game of the oldest lineage, the ancestor of the two -- loops above (FORGE.md section 14.12): the same states and screens, a simpler judging (one -- input, a mash, a button with a direction, two directions, a skip), levels made of segments, -- and a map. A game of this lineage keeps its data as functions of its own main.singe -- @@ -10806,6 +11859,16 @@ AUTHOR.behaviours.kimmy = { -- state; the loop itself is this. The numbers are the game's own, read from its globals. local R = {} +-- The segmented lineage's functions for the shared screens: its own start, the board test in its +-- game's own Lua, and a step back over segments; the shared ones otherwise. +local SEG_FNS = setmetatable({ + newScore = function(q, score) return q.newScore(score) end, + stepBack = function(q) + if q.iSegPointer > 0 then + q.iSegPointer = q.iSegPointer - 1 + end + end +}, { __index = function(_, name) return R[name] or Q[name] end }) function R.addPoints(q, thisMuch) @@ -10837,7 +11900,7 @@ end -- The sound of a right move: the framework's sndright, or the one the game's tweaks name --- (Freedom Fighter shoots). +-- (a shooting game's shot). local function rightSound(q) return q[q.tweaks.rightSound or "sndright"] end @@ -10876,15 +11939,15 @@ end -- A move that is a button with a direction, or two directions: wanted both down, failed by -- any other input or the window's last frame. -local RDG_PAIRS = nil +local PAIR_SWITCHES = nil local function pairFor(q, kind) - if RDG_PAIRS == nil then - RDG_PAIRS = { [q.ACTUP] = { "p1BUTTON1", "p1UP" }, [q.ACTDOWN] = { "p1BUTTON1", "p1DOWN" }, [q.ACTLEFT] = { "p1BUTTON1", "p1LEFT" }, [q.ACTRIGHT] = { "p1BUTTON1", "p1RIGHT" }, + if PAIR_SWITCHES == nil then + PAIR_SWITCHES = { [q.ACTUP] = { "p1BUTTON1", "p1UP" }, [q.ACTDOWN] = { "p1BUTTON1", "p1DOWN" }, [q.ACTLEFT] = { "p1BUTTON1", "p1LEFT" }, [q.ACTRIGHT] = { "p1BUTTON1", "p1RIGHT" }, [q.UPLEFT] = { "p1UP", "p1LEFT" }, [q.UPRIGHT] = { "p1UP", "p1RIGHT" }, [q.DOWNLEFT] = { "p1DOWN", "p1LEFT" }, [q.DOWNRIGHT] = { "p1DOWN", "p1RIGHT" } } end - return RDG_PAIRS[kind] + return PAIR_SWITCHES[kind] end @@ -10907,16 +11970,6 @@ local function wrongMove(q) end --- The later copies (Dragon Trainer's generation, the ones with a level order) test a clip's --- end with "at or past" where the first ones test "at". -local function clipEnded(q) - if q.later then - return q.currentFrame >= q.iFrameEnd - end - return q.currentFrame == q.iFrameEnd -end - - -- The segment begins: its intro clip, or the disc put at its start (resumed a move on from a -- continue). local function startSegment(q, thisLevel) @@ -11010,7 +12063,7 @@ function R.doLevel(q) startSegment(q, thisLevel) end elseif q.lvlState == QTE.branch01 then - if clipEnded(q) or q.p1BUTTON1 or (q.myButton == SWITCH_BUTTON3) then + if Q.clipEnded(q) or q.p1BUTTON1 or (q.myButton == SWITCH_BUTTON3) then q.p1BUTTON1 = false q.bShowLvl, q.bShowSkip = false, false if q.currentFrame ~= q.iFrameEnd then @@ -11029,7 +12082,7 @@ function R.doLevel(q) q.lvlState = QTE.lvlEnd end elseif q.lvlState == QTE.branch05 then - if clipEnded(q) then + if Q.clipEnded(q) then for j = 1, q.stage[thisLevel][q.SEGMENTCOUNT] do q.segment[thisLevel][j][q.SEGMENTCOMPLETE] = true end @@ -11079,7 +12132,7 @@ function R.doLevel(q) end elseif (q.lvlState == QTE.branch08) and q.later then -- The extra death clip has ended. - if clipEnded(q) then + if Q.clipEnded(q) then afterDeath(q, thisLevel) end elseif q.lvlState == QTE.branch08 then @@ -11090,7 +12143,7 @@ function R.doLevel(q) end elseif (q.lvlState == QTE.branch09) and q.later then -- The "get up" clip has ended: the segment begins. - if clipEnded(q) then + if Q.clipEnded(q) then q.bShowGet, q.bShowScore = false, true startSegment(q, thisLevel) end @@ -11184,7 +12237,7 @@ function R.doLevel(q) q.bAct = true local other = false -- A down action whose row carries a zone (columns 7 to 10) is a shot at that part - -- of the picture (Freedom Fighter): the button with the pointer inside it. + -- of the picture, as in a shooting game: the button with the pointer inside it. local shot = (kind == q.ACTDOWN) and (m[7] ~= nil) for _, name in ipairs({ "p1UP", "p1DOWN", "p1LEFT", "p1RIGHT", "p1BUTTON1", "p1BUTTON2", "p1BUTTON3" }) do @@ -11239,7 +12292,7 @@ function R.doLevel(q) if q.later then q.bShowScore = false end - if clipEnded(q) then + if Q.clipEnded(q) then if q.later and q.ShowSupDeath then -- The later copies can play a second clip after the death. Q.setupClip(q, q.offsetSupDeath, q.offsetSupDeathEnd) @@ -11457,7 +12510,7 @@ function R.doIntro(q) q.lvlState = QTE.branch01 q.bShowCredits, q.bShowLCD, q.bShowLives, q.bCheckForCredits = true, true, false, true elseif q.lvlState == QTE.branch01 then - if clipEnded(q) or q.p1BUTTON1 then + if Q.clipEnded(q) or q.p1BUTTON1 then q.p1BUTTON1 = false discSkipToFrame(q.frameCommands) discPause() @@ -11493,7 +12546,7 @@ function R.doIntro(q) quitting() end elseif q.lvlState == QTE.branch04 then - if clipEnded(q) then + if Q.clipEnded(q) then q.lvlState = QTE.lvlSetup elseif q.later and q.p1BUTTON1 then q.p1BUTTON1 = false @@ -11527,61 +12580,6 @@ function R.doIntro(q) end -function R.doContinue(q) - if q.lvlState == QTE.lvlSetup then - Q.setupClip(q, q.offsetContinue, q.offsetContinueend) - q.bShowLives, q.bShowLvl, q.bShowScene, q.bShowScore, q.bShowAction = false, false, false, false, false - q.bShowCredits, q.bTestMash, q.iMash = true, false, 0 - q.lvlState = QTE.lvlRunning - elseif q.lvlState == QTE.lvlRunning then - if clipEnded(q) then - q.lvlState = QTE.lvlEnd - elseif q.p1START1 then - q.p1START1 = false - if (q.iCredits > 0) or (q.dip_CoinsPerCredit == q.DOPT_FREEPLAY) then - q.bSkipIntroClip = true - if q.iSegPointer > 0 then - q.iSegPointer = q.iSegPointer - 1 - end - R.startGame(q) - end - end - elseif q.lvlState == QTE.lvlEnd then - q.lvlState = QTE.lvlSetup - q.bSkipIntroClip = false - if q.newScore(q.iScore) then - q.currentLevel = QTE.levelHighScore - q.bGOAlt = true - else - q.currentLevel = QTE.levelGameOver - end - end -end - - -function R.doGameOver(q) - if q.lvlState == QTE.lvlSetup then - q.bShowLives, q.bShowLvl, q.bShowScene, q.bShowScore, q.bShowCredits, q.bShowAction = false, false, false, false, false, false - q.bTestMash, q.iMash = false, 0 - if q.bGOAlt then - Q.setupClip(q, q.offsetGameOverAlt, q.offsetGameOverAltend) - q.bGOAlt = false - else - Q.setupClip(q, q.offsetGameOver, q.offsetGameOverend) - end - q.lvlState = QTE.lvlRunning - elseif q.lvlState == QTE.lvlRunning then - if clipEnded(q) then - q.bShowScore = false - q.lvlState = QTE.lvlEnd - end - elseif q.lvlState == QTE.lvlEnd then - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelIntro - end -end - - -- The level cleared: the clear clip, then the score and the bonus shown to a timer, and on. function R.doFinish(q) -- The first copies count the steps from branch02, the later from branch01. @@ -11594,7 +12592,7 @@ function R.doFinish(q) Q.setupClip(q, q.offsetClear, q.offsetClearend) q.lvlState = step[1] elseif q.lvlState == step[1] then - if clipEnded(q) then + if Q.clipEnded(q) then Q.timerON(q, 1) discPause() q.lvlState = step[2] @@ -11657,8 +12655,8 @@ function R.doHighScore(q) elseif (q.lvlState == QTE.branch03) or (q.lvlState == QTE.branch04) then if q.lvlState == QTE.branch03 then if q.currentFrame == q.iFrameEnd then - if q.sdq then - -- Super Don Quixote's board ends without the two-second hold. + if q.qteStyle == "trophies" then + -- The trophy loop's board ends without the two-second hold. q.lvlState = QTE.lvlEnd else discPause() @@ -11722,12 +12720,12 @@ end -- ----- The behaviour ------------------------------------------------------------------------- -AUTHOR.behaviours.rdg = { - help = "Plays a laserdisc quick-time-event game of RDG's map-mode lineage (the ancestor of the Karis framework): levels of segments, a move an input or a mash or a pair of inputs in a window, a map between levels. The game's own files are loaded and its level, move, death-clip, order, and map functions run as they are; the loop is this. Written by util/forgePortKaris.lua from a game's script.", +QTE_STYLES.segments = { + help = "Plays a laserdisc quick-time-event game of levels of segments: a move an input or a mash or a pair of inputs in a window, a map between levels. The game's own files are loaded and its level, move, death-clip, order, and map functions run as they are; the loop is this. The port converter writes the tables from an existing game's script.", params = {}, attach = function(instance, params) local data = AUTHOR_GAME.qte or {} - local q = { instance = instance, snapshot = data, rdg = true, namedSounds = true, loadsFiles = true } + local q = { instance = instance, snapshot = data, qteStyle = "segments", namedSounds = true, loadsFiles = true } instance.q = q instance.hearsAll = true @@ -11761,9 +12759,17 @@ AUTHOR.behaviours.rdg = { end -- What this title does differently from the lineage, named by the converter. q.tweaks = data.tweaks or {} - -- The later copies (Dragon Trainer's generation) declare a level order; they number + -- The later copies of the lineage declare a level order; they number -- some states differently, test clip ends with "at or past", and add a game type. q.later = (q.LvlOrder ~= nil) + -- How the lineage plays the screens it shares with the other styles. + q.optionDefaults = { + clipEndExact = not q.later, -- The first copies test a clip's end with "at". + diffChoiceAfterGameOver = false, -- A continue's "no difficulty" outlives the game over. + continueDeclines = false, -- BUTTON2 does not turn the continue down. + gameOverAfterContinue = "seek" -- The game over is always sought. + } + q.qteFns = SEG_FNS if q.later then q.iPenal = ({ [0] = 0, [1] = q.PenalNorm, [2] = q.PenalHard })[q.dip_Difficulty] or 0 end @@ -11808,7 +12814,7 @@ AUTHOR.behaviours.rdg = { q.bPause = true q.lvlState = QTE.lvlRunning elseif q.lvlState == QTE.lvlRunning then - if clipEnded(q) then + if Q.clipEnded(q) then discPause() q.lvlState = QTE.lvlEnd end @@ -11842,9 +12848,9 @@ AUTHOR.behaviours.rdg = { elseif level == q.levelMenuScreen then q.doLevelSelect() elseif level == QTE.levelContinue then - R.doContinue(q) + Q.doContinue(q) elseif level == QTE.levelGameOver then - R.doGameOver(q) + Q.doGameOver(q) elseif level == QTE.levelHighScore then R.doHighScore(q) elseif level == QTE.levelMovie then @@ -11938,12 +12944,12 @@ AUTHOR.behaviours.rdg = { -- A vocabulary behaviour that plays a game over the game's own files (a per-game port of a -- hand-written program) reaches the shim through these: the state table's environment, and -- a file of the game's run in it, beside the description. --- The one-player American Laser Games editions' crosshair and recoil, as their frame loops +-- The one-player gun games' crosshair and recoil, as their frame loops -- draw them after the level ran: the crosshair where the pointer is, and for the frames of -- the recoil the flash where the shot went; an edition that swaps the crosshair's sprite for -- the flash's puts it back by the crosshair dip when the recoil ends. -- opts.highScore the recoil is left alone on the board --- opts.cannon no crosshair while the cannon fires (Space Pirates) +-- opts.cannon no crosshair while the cannon fires -- opts.reload the folder of the crosshair pictures, for an edition that swaps them function authorDrawGun(q, opts) local env = q.env @@ -11976,14 +12982,14 @@ end ALG_CROSSHAIRS = { "crosshaira.png", "crosshairb.png", "crosshairc.png", "crosshaird.png", "crosshaire.png" } --- The Hypseus multiplayer editions' two guns: for each, the crosshair, the recoil as a sheet +-- The two-player gun games' two guns: for each, the crosshair, the recoil as a sheet -- of frames counted after the level ran and frozen where the shot went, and the hand's -- animation the same way. The second gun's names end in 2. --- opts.cannon no crosshair while the cannon fires (Space Pirates) --- opts.highScore the recoil is left alone on the boards (Johnny Rock) --- opts.resets the counts go back to zero when done (Johnny Rock) +-- opts.cannon no crosshair while the cannon fires +-- opts.highScore the recoil is left alone on the boards +-- opts.resets the counts go back to zero when done -- opts.reload the folder of the crosshair pictures, swapped in by the dip through the --- recoil (Johnny Rock; the second gun's pictures end in p2) +-- recoil (the second gun's pictures end in p2) function authorDrawGuns(q, opts) local env = q.env @@ -12031,6 +13037,12 @@ function authorDrawGuns(q, opts) end +-- The qteHud look's drawing, reached by name: the looks come before the loops in this file. +function authorQteHud(q) + Q.hudDraw(q) +end + + function authorFlushDraws(q) Q.flushDraws(q) fontSelect(AUTHOR_FONT) @@ -12062,9 +13074,9 @@ function authorRunScript(q, name) end --- ===== The Time Gal (Singe Edition) game loop ================================================ +-- ===== The checkpoint quick-time-event loop ==================================================== -- --- RDG2010's HD remake of the Singe 1 Time Gal keeps a compact loop of its own, not the map-mode +-- One HD remake keeps a compact loop of its own, not the map-mode -- one: a level's moves come from arrayEasy, arrayNormal, or arrayHard by difficulty (mirrored -- by a random draw), play has checkpoints resumed after a death, a time-stop is a choice of -- three, a tally pays a bonus by deaths, a quota gives a life, and chooseLevel picks the next @@ -12547,12 +13559,12 @@ function T.doHighScore(q) end -AUTHOR.behaviours.timegal = { - help = "Plays RDG2010's Time Gal (Singe Edition): levels of moves from a difficulty array (mirrored by a random draw), checkpoints, a time-stop choice of three, a tally by deaths, a life per quota, and a level order by tiers, at random, or in sequence. The game's own files are loaded and its data and order functions run as they are; the loop is this. Written by util/forgePortKaris.lua from the game's script.", +QTE_STYLES.checkpoints = { + help = "Plays a laserdisc quick-time-event game of levels with checkpoints: moves from a difficulty array (mirrored by a random draw), checkpoints, a time-stop choice of three, a tally by deaths, a life per quota, and a level order by tiers, at random, or in sequence. The game's own files are loaded and its data and order functions run as they are; the loop is this. The port converter writes the tables from an existing game's script.", params = {}, attach = function(instance, params) local data = AUTHOR_GAME.qte or {} - local q = { instance = instance, snapshot = data, rdg = true, timegal = true, namedSounds = true, loadsFiles = true } + local q = { instance = instance, snapshot = data, qteStyle = "checkpoints", namedSounds = true, loadsFiles = true } instance.q = q instance.hearsAll = true @@ -12699,11 +13711,11 @@ AUTHOR.behaviours.timegal = { } --- ===== The Super Don Quixote game loop ======================================================== +-- ===== The trophy quick-time-event loop ======================================================== -- -- One hybrid: map-mode data (the level, scene, death-clip, order, and map functions of its --- main.singe, run as they are through the shim as the rdg loop runs them) under a level loop --- of the Karis 3.31c shape (branch11 the right move, branch02 a wrong one with the hints +-- main.singe, run as they are through the shim as the segmented loop runs them) under a level loop +-- of the qte loop's shape (branch11 the right move, branch02 a wrong one with the hints -- frame in branch03, holds, mashes, doubles, paths, and choices, though its rows use only the -- five plain kinds), with screens of its own: a difficulty select, a level clear with a -- rolling bonus, a finish with a rolling percent, trophies, and two hardcoded level orders @@ -12779,41 +13791,6 @@ function S.checkHold(q, playerMove, curMove) end -function S.checkLet(q, playerMove, curMove) - local m = q.move[q.currentMove] - - if (q.currentFrame == m[q.inputFrmStart]) and (playerMove == q.NOMOVE) then - return q.MOVEFAIL - elseif (q.currentFrame == m[q.inputFrmEnd] - 1) and (playerMove == q.NOMOVE) then - return curMove - end - - return q.MOVEPENDING -end - - -function S.checkMash(q, playerMove, curMove) - local z = q.MOVEPENDING - - if playerMove == q.BUTTON1 then - q.bTestMash = false - if q.iMash >= q.mashCounter then - z = curMove - end - else - if q.iMash > 0 then - q.iMash = q.iMash - q.unMash - end - if playerMove ~= q.NOMOVE then - q.iMash = 0 - z = q.MOVEFAIL - end - end - - return z -end - - function S.checkDouble(q, playerMove, curMove) local z = q.MOVEPENDING @@ -12843,7 +13820,7 @@ end -- A wrong move: the hints frame first when the dip says so, else the death (nothing in the -- practice game type). -local function sdqWrong(q) +local function trophyWrong(q) if q.dip_Hints then Q.sound(q, q.sndwrong) discSkipToFrame(q.frameHints) @@ -12864,7 +13841,7 @@ end -- A path's option taken: a jump to a move, or a death when the target is over a thousand. -local function sdqPath(q, target, aEnd, aJump) +local function trophyPath(q, target, aEnd, aJump) q.iPath = target if q.iPath > 1000 then q.move[q.currentMove][q.moveDeath] = q.iPath - 1000 @@ -12881,7 +13858,7 @@ end -- A death clip has ended: a life was already taken by setupDeathClip; on by the rewind dip. -local function sdqAfterDeath(q, thisLevel) +local function trophyAfterDeath(q, thisLevel) q.stage[thisLevel][q.DEATHCOUNT] = q.stage[thisLevel][q.DEATHCOUNT] + 1 if q.iLives <= 0 then q.lvlState = q.lvlEnd @@ -12915,7 +13892,7 @@ end -- The scene begins: its intro clip, or the disc put at its start. -local function sdqStartScene(q, thisLevel) +local function trophyStartScene(q, thisLevel) if not q.stage[thisLevel][q.LEVELSTARTED] then q.stage[thisLevel][q.LEVELSTARTED] = true if q.bSave and (q.currentMove ~= 1) then @@ -12988,7 +13965,7 @@ function S.doLevel(q) q.lvlState = q.lvlRunning end elseif q.lvlState == q.branch02 then - sdqWrong(q) + trophyWrong(q) elseif q.lvlState == q.branch03 then if Q.timerDue(q) then q.setupDeathClip(q.thisMove) @@ -13023,12 +14000,12 @@ function S.doLevel(q) elseif q.lvlState == q.branch07 then -- The extra death clip has ended. if q.currentFrame == q.iFrameEnd then - sdqAfterDeath(q, thisLevel) + trophyAfterDeath(q, thisLevel) end elseif q.lvlState == q.branch08 then -- The "get ready" clip has ended: the scene begins. if q.currentFrame == q.iFrameEnd then - sdqStartScene(q, thisLevel) + trophyStartScene(q, thisLevel) end elseif q.lvlState == q.branch09 then -- The "get ready" clip after a death has ended: back to the move after it. @@ -13163,7 +14140,7 @@ function S.doLevel(q) q.lvlState = q.branch11 end elseif kind == q.LETGO then - q.thisMove = S.checkLet(q, S.scanInput(q), kind) + q.thisMove = Q.checkLet(q, S.scanInput(q), kind) if judged(q.thisMove) then q.lvlState = q.branch11 end @@ -13175,7 +14152,7 @@ function S.doLevel(q) q.unMash = ({ [q.MASH] = 0.15, [q.MASHMIN] = 0.12, [q.MASHMAX] = 0.18 })[kind] q.mashCounter = q.dip_Difficulty + (m[q.inputFrmEnd] - m[q.inputFrmStart]) / 10 end - q.thisMove = S.checkMash(q, q.thisMove, kind) + q.thisMove = Q.checkMash(q, q.thisMove, kind) if judged(q.thisMove) then q.bTestMash, q.iMash, q.bCalc = false, 0, true q.lvlState = q.branch11 @@ -13221,15 +14198,15 @@ function S.doLevel(q) local p = q.path[q.curPath] if q.thisMove == p[1] then - sdqPath(q, p[4], p[5] - 1, p[7]) + trophyPath(q, p[4], p[5] - 1, p[7]) elseif q.thisMove == p[2] then if p[3] == 0 then - sdqPath(q, p[5], 0, 0) + trophyPath(q, p[5], 0, 0) else - sdqPath(q, p[5], p[6] - 1, p[7]) + trophyPath(q, p[5], p[6] - 1, p[7]) end elseif q.thisMove == p[3] then - sdqPath(q, p[6], 0, 0) + trophyPath(q, p[6], 0, 0) else q.setupDeathClip(q.thisMove) end @@ -13244,12 +14221,12 @@ function S.doLevel(q) if q.thisMove ~= q.NOMOVE then if q.thisMove == q.BUTTON1 then - sdqPath(q, p[4], p[5] - 1, p[7]) + trophyPath(q, p[4], p[5] - 1, p[7]) else q.setupDeathClip(q.thisMove) end elseif q.currentFrame >= m[q.inputFrmEnd] then - sdqPath(q, p[5], 0, 0) + trophyPath(q, p[5], 0, 0) end elseif kind == q.SKIP then q.thisMove = S.checkSkip(q, S.scanInput(q), kind) @@ -13270,7 +14247,7 @@ function S.doLevel(q) Q.setupClip(q, q.offsetSupDeath, q.offsetSupDeathEnd) q.lvlState = q.branch07 else - sdqAfterDeath(q, thisLevel) + trophyAfterDeath(q, thisLevel) end end elseif q.lvlState == q.lvlEnd then @@ -13284,7 +14261,7 @@ function S.doLevel(q) q.currentLevel = q.newScore(q.iScore) and q.levelHighScore or q.levelGameOver end else - local order = q.snapshot.sdq or {} + local order = q.snapshot.trophyOrder or {} if q.BeatLevel(thisLevel) then if q.dip_GameType ~= 4 then @@ -13337,7 +14314,7 @@ function S.doLevel(q) end --- A choice (none of the game's rows make one): the Karis shape, a wrong option a death. +-- A choice (none of the game's rows make one): the qte loop's shape, a wrong option a death. function S.doChoose(q) local m = q.move[q.currentMove] @@ -13699,15 +14676,17 @@ function S.doContinue(q) end -AUTHOR.behaviours.sdq = { - help = "Plays Super Don Quixote: map-mode levels, scenes, death clips, order, and map from the game's own files, judged as the Karis 3.31c framework judges, with its difficulty select, level clear, finish, and trophies. Written by util/forgePortKaris.lua from the game's script.", +QTE_STYLES.trophies = { + help = "Plays a laserdisc quick-time-event game of levels, scenes, and trophies: map-mode levels, scenes, death clips, order, and map from the game's own files, judged as the qte behaviour judges, with its difficulty select, level clear, finish, and trophies. The port converter writes the tables from an existing game's script.", params = {}, attach = function(instance, params) - -- The state the rdg loop keeps, and then this game's own. - AUTHOR.behaviours.rdg.attach(instance, params) + -- The state the segmented loop keeps, and then this game's own. + QTE_STYLES.segments.attach(instance, params) local q = instance.q - q.sdq, q.later = true, false + q.qteStyle, q.later = "trophies", false + q.optionDefaults.clipEndExact = true + q.optionDefaults.mashClearsButton = false q.percent = {} for k, entry in ipairs(q.snapshot.percents or {}) do q.percent[k] = { entry[1], entry[2] } @@ -13775,7 +14754,7 @@ AUTHOR.behaviours.sdq = { elseif level == q.levelContinue then S.doContinue(q) elseif level == q.levelGameOver then - R.doGameOver(q) + Q.doGameOver(q) elseif level == q.levelHighScore then R.doHighScore(q) elseif level == q.levelFinish then @@ -13797,6 +14776,32 @@ AUTHOR.behaviours.sdq = { end, on = function(instance, name, event) -- The map-mode handler: the same switches, the mash count, the coins, and the quit. - AUTHOR.behaviours.rdg.on(instance, name, event) + QTE_STYLES.segments.on(instance, name, event) + end +} + + +-- The one quick-time-event behaviour (FORGE.md 15.4): the description's qte.options.style picks +-- the loop, and everything else is the loop's own. The loops are folded into one a piece at a +-- time (step 4 of 15.8); until then each style keeps its own. +AUTHOR.behaviours.qte = { + help = "Plays a laserdisc quick-time-event game from the tables under game.qte: an attract loop, credits, levels of scenes with moves to answer in a window of frames, death clips, continues, the settings, and the game's own add-ons. qte.options.style says how its moves are judged and its levels run: windows (the default: a move answered in a window, compound moves too), held (moves judged by what is held together: combos, mashes, holds, sequences, paths, timed and yes/no branches, tilt, a life bar, die and retry, a new-game menu, a level select), segments (levels of segments, a map between them, the game's own level functions run as they are), checkpoints (checkpoints, a time-stop choice of three, a tally by deaths, a life per quota), or trophies (segmented levels judged the windows way, a difficulty select, a level-clear screen, trophies). The port converter writes the tables from an existing game's script.", + params = {}, + attach = function(instance, params) + local options = (AUTHOR_GAME.qte or {}).options or {} + local style = options.style or "windows" + local loop = QTE_STYLES[style] + + if loop == nil then + error("qte: no style called " .. tostring(style) .. " (windows, held, segments, checkpoints, or trophies)") + end + instance.qteLoop = loop + loop.attach(instance, params) + end, + step = function(instance) + instance.qteLoop.step(instance) + end, + on = function(instance, name, event) + instance.qteLoop.on(instance, name, event) end } diff --git a/forge/Forge.singe b/forge/Forge.singe index c512f7b77..0cc09f93a 100644 --- a/forge/Forge.singe +++ b/forge/Forge.singe @@ -105,6 +105,20 @@ local MODES = { "entities", "types", "rules", "tracks", "dialogues" } local FILE_KINDS = { png = true, jpg = true, jpeg = true, svg = true, glb = true, wav = true, ogg = true, mp3 = true, flac = true, mkv = true, mp4 = true, mpg = true, rml = true, hdr = true, ttf = true, singe = true } local FILE_DEPTH = 2 -- How far under the game's directory the file picker looks. local FILE_NONE = "(none)" -- The file picker's first entry: no file. +-- Who made the game, as the description's credits table carries it: the field, and the games.dat +-- key a release writes it under, which is what the menu's details panel shows. notes (lines of +-- credit the fields have no place for) and licenses (files that travel with the release) are the +-- two lists beside these. +local CREDIT_FIELDS = { + { field = "developer", key = "DEVELOPER" }, + { field = "publisher", key = "PUBLISHER" }, + { field = "year", key = "YEAR" }, + { field = "platform", key = "PLATFORM" }, + { field = "genre", key = "GENRE" }, + { field = "creator", key = "CREATOR" }, + { field = "source", key = "SOURCE" }, + { field = "description", key = "DESCRIPTION" } +} local FRAME_STEP = 1 local FRAME_LEAP = 10 local PICK_REACH = 30 -- Pixels a press may be from an entity's projected position to pick it. @@ -1308,12 +1322,56 @@ function forgeExport(folder, name) -- The id travels with the release: it is what the master service keys scores and updates -- on, and a game without one cannot be published. Every description has one from the moment -- it is first saved (forgeIdFor). + -- The credits go with it, under the keys the menu's details panel shows, so whoever made the + -- game is named wherever it is played; lines the keys have no place for go in CREDITS.txt. + local credits = FORGE.game.credits or {} + local lines = { + string.format("\t\tTITLE = %q,", title), + string.format("\t\tGAME_ID = %q,", forgeIdFor(FORGE.game)), + string.format("\t\tSCRIPT = %q,", base .. "/" .. script .. ".singe"), + string.format("\t\tVIDEO = %q,", video), + string.format("\t\tRESOLUTION_X = %d,", overlayGetWidth()), + string.format("\t\tRESOLUTION_Y = %d,", overlayGetHeight()) + } + + -- What the game needs of a machine, as far as its layers say (PLAN.md section 57): a machine + -- that lacks one is told so in the menu rather than failing at start. --pack adds whatever a + -- run of the game used beyond these. + local needs = {} + + for _, layer in ipairs(FORGE.game.layers or {}) do + if (layer.kind == "scene3d") and not needs.SCENE then + needs.SCENE = true + needs[#needs + 1] = "\"SCENE\"" + elseif (layer.kind == "hud") and not needs.GUI then + needs.GUI = true + needs[#needs + 1] = "\"GUI\"" + end + end + if #needs > 0 then + lines[#lines + 1] = "\t\tREQUIRES = { " .. table.concat(needs, ", ") .. " }," + end + for _, credit in ipairs(CREDIT_FIELDS) do + local value = credits[credit.field] + + if type(value) == "number" then + lines[#lines + 1] = string.format("\t\t%s = %d,", credit.key, value) + elseif (type(value) == "string") and (value ~= "") then + lines[#lines + 1] = string.format("\t\t%s = %q,", credit.key, value) + end + end dat = io.open(folder .. "/games.dat", "w") if dat ~= nil then - dat:write(string.format("-- Written by Forge.\nGAMES = {\n\t{\n\t\tTITLE = %q,\n\t\tGAME_ID = %q,\n\t\tSCRIPT = %q,\n\t\tVIDEO = %q,\n\t\tRESOLUTION_X = %d,\n\t\tRESOLUTION_Y = %d\n\t}\n}\n", - title, forgeIdFor(FORGE.game), base .. "/" .. script .. ".singe", video, overlayGetWidth(), overlayGetHeight())) + dat:write("-- Written by Forge.\nGAMES = {\n\t{\n" .. table.concat(lines, "\n") .. "\n\t}\n}\n") dat:close() end + if credits.notes and (#credits.notes > 0) then + dat = io.open(folder .. "/CREDITS.txt", "w") + if dat ~= nil then + dat:write(title .. "\n\n" .. table.concat(credits.notes, "\n") .. "\n") + dat:close() + end + end FORGE.message = "released to " .. folder forgeRefresh() @@ -1449,6 +1507,9 @@ function forgeFilesNamed(game) end take(game.vocabulary) + for _, name in ipairs((game.credits or {}).licenses or {}) do + take(name) + end for _, layer in ipairs(game.layers or {}) do fromParams(layer, (AUTHOR.layers[layer.kind] or {}).params) end @@ -4369,6 +4430,11 @@ end function forgeRoomFieldNames() local names = { "title", "players", "vars", "verbs", "layers", "vocabulary" } + for _, credit in ipairs(CREDIT_FIELDS) do + names[#names + 1] = "credits." .. credit.field + end + names[#names + 1] = "credits.notes" + names[#names + 1] = "credits.licenses" for _, layer in ipairs(FORGE.game.layers or {}) do local entry = AUTHOR.layers[layer.kind] @@ -4412,6 +4478,15 @@ function forgeRoomGet(room, key) return listText(FORGE.game.layers) elseif key == "vocabulary" then return FORGE.game.vocabulary + elseif group == "credits" then + local credits = FORGE.game.credits or {} + + if field == "notes" then + return table.concat(credits.notes or {}, " | ") + elseif field == "licenses" then + return table.concat(credits.licenses or {}, ", ") + end + return (credits[field] ~= nil) and tostring(credits[field]) or nil elseif group ~= nil then local layer = layerOf(group) @@ -4450,6 +4525,31 @@ function forgeRoomSet(room, key, value) if key == "title" then FORGE.game.title = tostring(value) + elseif group == "credits" then + local credits = FORGE.game.credits or {} + local text = (value ~= nil) and tostring(value) or "" + local list = {} + + if field == "notes" then + for line in string.gmatch(text, "[^|]+") do + line = line:match("^%s*(.-)%s*$") + if line ~= "" then + list[#list + 1] = line + end + end + credits.notes = (#list > 0) and list or nil + elseif field == "licenses" then + for name in string.gmatch(text, "[^,]+") do + name = name:match("^%s*(.-)%s*$") + if name ~= "" then + list[#list + 1] = name + end + end + credits.licenses = (#list > 0) and list or nil + else + credits[field] = (text ~= "") and (tonumber(text) or text) or nil + end + FORGE.game.credits = (next(credits) ~= nil) and credits or nil elseif key == "vocabulary" then -- The game's own vocabulary, loaded as it is named so the panels offer what it adds. FORGE.game.vocabulary = ((value ~= nil) and (value ~= "")) and tostring(value) or nil diff --git a/forge/games.dat b/forge/games.dat index 0a701a794..978e19e30 100644 --- a/forge/games.dat +++ b/forge/games.dat @@ -5,6 +5,7 @@ GAMES = { { TITLE = "Forge", SCRIPT = "Forge/Forge.singe", + REQUIRES = { "GUI" }, LEGACY_SPRITE_ARGS = false, VIDEO = "Singe/menuBackground.mkv", STRETCH = false, diff --git a/src/main.c b/src/main.c index cf3034f96..163d4472f 100644 --- a/src/main.c +++ b/src/main.c @@ -39,6 +39,7 @@ #endif #include <signal.h> +#include <time.h> #ifndef _WIN32 #include <execinfo.h> #include <sys/utsname.h> @@ -84,6 +85,7 @@ #define USAGE_OPTION_WIDTH 28 // The longest long form is --trigger_threshold=PERCENT plus a space #define USAGE_LINE_WIDTH 79 // Help text wraps so no line is wider than this #define CRASH_FRAMES_MAX 64 +#define TERMINATE_TWIN_MS 1000 // A second SIGTERM or SIGINT this soon is the first one sent twice #define SCREEN_MIN 1 // --screen numbers the displays from 1, as Hypseus does #define IDLE_EXIT_MIN 0 // --idleexit seconds; 0 never quits #define IDLE_EXIT_MAX 86400 // ... and a day is as long as anyone can mean @@ -333,6 +335,9 @@ static char *_executable = NULL; // SIGTERM or SIGINT arrived: the whole of Singe ends, the menu a game came from included. static volatile sig_atomic_t _terminated = 0; +#ifndef _WIN32 +static struct timespec _terminatedAt; // When the first SIGTERM or SIGINT came. +#endif const char *mainExecutablePath(void) { @@ -1713,7 +1718,7 @@ static void _resolveFiles(const char *exeName, ConfigT *conf) { static bool _runTool(const ConfigT *conf) { switch (conf->toolMode) { case TOOL_PACK: - return packGame(conf->toolSource, conf->scriptFile); + return packGame(conf->toolSource, conf->scriptFile, conf->dataDirBase); case TOOL_PATCH: return packPatch(conf->toolSource, conf->scriptFile); @@ -1878,13 +1883,30 @@ static void _stripDatabaseNames(char *path) { // SIGTERM and SIGINT: noted, for the frame loop to end the script and main() to run nothing after -// it. A second one while that is under way means it is stuck, and ends the process at once. +// it. A second one while that is under way means it is stuck, and ends the process at once -- +// unless it came within TERMINATE_TWIN_MS of the first: timeout, and anything else that signals +// the process and then its group, sends one request twice. static void _terminateHandler(int signalNumber) { +#ifndef _WIN32 + struct timespec now; + int64_t elapsedMS = 0; +#endif + if (_terminated) { +#ifndef _WIN32 + clock_gettime(CLOCK_MONOTONIC, &now); + elapsedMS = (int64_t)(now.tv_sec - _terminatedAt.tv_sec) * 1000 + (now.tv_nsec - _terminatedAt.tv_nsec) / 1000000; + if (elapsedMS < TERMINATE_TWIN_MS) { + return; + } +#endif signal(signalNumber, SIG_DFL); raise(signalNumber); return; } +#ifndef _WIN32 + clock_gettime(CLOCK_MONOTONIC, &_terminatedAt); +#endif _terminated = 1; } @@ -2252,30 +2274,7 @@ ConfigT *cloneConf(const ConfigT *conf) { // Builds and creates dataDirBase + directory of filename. Returns a new string or NULL on failure. char *createDataDir(const char *dataDirBase, const char *filename) { - const char separator = utilGetPathSeparator(); - char *relative = utilGetUpToLastPathComponent(filename); - char *start = relative; - char *path = NULL; - char *p = NULL; - - // Keep absolute paths inside the base: drop any drive letter and leading separators. - if (isalpha((unsigned char)start[0]) && (start[1] == ':')) { - start += 2; - } - while (*start == separator) { - start++; - } - // Neutralize ".." components. - for (p = start; *p != 0; p++) { - if ((p[0] == '.') && (p[1] == '.') && ((p == start) || (p[-1] == separator)) && ((p[2] == separator) || (p[2] == 0))) { - p[0] = '_'; - p[1] = '_'; - } - } - _stripDatabaseNames(start); - path = utilCreateString("%s%s", dataDirBase, start); - free(relative); - utilFixPathSeparators(&path, true); + char *path = dataDirPath(dataDirBase, filename); // Try to create data directory to ensure it exists. if (!utilMkDirP(path, DIRECTORY_MODE)) { @@ -2308,6 +2307,38 @@ char *createDataDirFor(const ConfigT *conf) { } +// dataDirBase + directory of filename, the data directory createDataDir makes, without making it: +// what --pack needs to find the record a game's runs left there. Returns a new string. +char *dataDirPath(const char *dataDirBase, const char *filename) { + const char separator = utilGetPathSeparator(); + char *relative = utilGetUpToLastPathComponent(filename); + char *start = relative; + char *path = NULL; + char *p = NULL; + + // Keep absolute paths inside the base: drop any drive letter and leading separators. + if (isalpha((unsigned char)start[0]) && (start[1] == ':')) { + start += 2; + } + while (*start == separator) { + start++; + } + // Neutralize ".." components. + for (p = start; *p != 0; p++) { + if ((p[0] == '.') && (p[1] == '.') && ((p == start) || (p[-1] == separator)) && ((p[2] == separator) || (p[2] == 0))) { + p[0] = '_'; + p[1] = '_'; + } + } + _stripDatabaseNames(start); + path = utilCreateString("%s%s", dataDirBase, start); + free(relative); + utilFixPathSeparators(&path, true); + + return path; +} + + void destroyConf(ConfigT **confPointer) { ConfigT *conf = *confPointer; diff --git a/src/main.h b/src/main.h index ace014381..6c44638fc 100644 --- a/src/main.h +++ b/src/main.h @@ -58,6 +58,7 @@ ConfigT *cloneConf(const ConfigT *conf); char *createDataDir(const char *dataDirBase, const char *filename); char *createDataDirFor(const ConfigT *conf); +char *dataDirPath(const char *dataDirBase, const char *filename); void destroyConf(ConfigT **confPointer); bool isFrameFileName(const char *filename); // What the trace header says about this machine, for the service tools to show as well. Each one diff --git a/src/model.c b/src/model.c index de53a2c9e..471f8fec7 100644 --- a/src/model.c +++ b/src/model.c @@ -1715,6 +1715,12 @@ int32_t modelInstance(int32_t model, int32_t parent) { } +// Whether a loaded model carries a skin: a game that instances one needs skinning (PLAN.md 57). +bool modelIsSkinned(int32_t model) { + return modelValid(model) && (_models[model].data != NULL) && (_models[model].data->skins_count > 0); +} + + const char *modelLastError(void) { return _error; } diff --git a/src/model.h b/src/model.h index b7b06aac7..f74381282 100644 --- a/src/model.h +++ b/src/model.h @@ -46,6 +46,7 @@ bool modelDelete(int32_t model); int32_t modelGetAnimationCount(int32_t model); const char *modelGetAnimationName(int32_t model, int32_t index); int32_t modelInstance(int32_t model, int32_t parent); +bool modelIsSkinned(int32_t model); const char *modelLastError(void); int32_t modelLoad(const char *name); void modelQuit(void); diff --git a/src/netTrust.c b/src/netTrust.c new file mode 100644 index 000000000..5ad3dae93 --- /dev/null +++ b/src/netTrust.c @@ -0,0 +1,230 @@ +/* + * + * 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: the machine's own trusted roots, added to a TLS context beside the bundle Singe ships. +// OpenSSL reads PEM files and hashed directories and nothing else, so each platform's store is +// read here into the context's X509_STORE: the distribution's file on Linux, the ROOT system +// store on Windows, the system anchors on macOS. A root present in two lists is added twice, +// which OpenSSL takes quietly. + + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include <openssl/err.h> +#include <openssl/x509.h> + +#if defined(_WIN32) +#include <windows.h> +#include <wincrypt.h> +#elif defined(__APPLE__) +#include <Security/Security.h> +#endif + +#include "netTrust.h" + + +#if !defined(_WIN32) && !defined(__APPLE__) +// Where Linux distributions keep their combined list, first match wins: Debian, Ubuntu, Arch, +// and Gentoo; Fedora and RHEL; openSUSE; OpenELEC; CentOS and RHEL 7 and later; Alpine. +static const char *const LINUX_BUNDLES[] = { + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/ca-bundle.pem", + "/etc/pki/tls/cacert.pem", + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + "/etc/ssl/cert.pem" +}; +#endif + + +#if defined(_WIN32) || defined(__APPLE__) +static bool _addDer(X509_STORE *store, const uint8_t *der, long length); +#endif +static bool _addDirectory(X509_STORE *store, const char *path); +static bool _addFile(X509_STORE *store, const char *path); +#if defined(__APPLE__) +static int32_t _addMac(X509_STORE *store); +#endif +#if defined(_WIN32) +static int32_t _addWindows(X509_STORE *store); +#endif +static void _note(char *report, size_t reportSize, const char *what, const char *path); + + +#if defined(_WIN32) || defined(__APPLE__) +// One certificate in DER form, as both platform stores hand them out. +static bool _addDer(X509_STORE *store, const uint8_t *der, long length) { + const unsigned char *cursor = der; + X509 *cert = d2i_X509(NULL, &cursor, length); + bool added = false; + + if (cert != NULL) { + added = (X509_STORE_add_cert(store, cert) == 1); + X509_free(cert); + } + + return added; +} +#endif + + +// A hashed directory of roots (SSL_CERT_DIR), looked up as a connection needs them. +static bool _addDirectory(X509_STORE *store, const char *path) { + return (path != NULL) && (path[0] != 0) && (X509_STORE_load_path(store, path) == 1); +} + + +// A PEM file of roots. A path that is not there is simply not added. +static bool _addFile(X509_STORE *store, const char *path) { + return (path != NULL) && (path[0] != 0) && (X509_STORE_load_file(store, path) == 1); +} + + +#if defined(__APPLE__) +// The system's anchor certificates. Only the anchors: the admin and user trust settings can also +// mark a certificate as distrusted, which a plain list of roots cannot express. +static int32_t _addMac(X509_STORE *store) { + CFArrayRef anchors = NULL; + CFIndex count = 0; + CFIndex x = 0; + int32_t added = 0; + + if (SecTrustCopyAnchorCertificates(&anchors) != errSecSuccess) { + return 0; + } + count = CFArrayGetCount(anchors); + for (x = 0; x < count; x++) { + SecCertificateRef cert = (SecCertificateRef)CFArrayGetValueAtIndex(anchors, x); + CFDataRef data = SecCertificateCopyData(cert); + + if (data != NULL) { + if (_addDer(store, CFDataGetBytePtr(data), (long)CFDataGetLength(data))) { + added++; + } + CFRelease(data); + } + } + CFRelease(anchors); + + return added; +} +#endif + + +#if defined(_WIN32) +// The ROOT system store. Windows fetches roots on demand, so a root it would trust can be absent +// here until something on the machine has needed it; the shipped bundle covers that gap. +static int32_t _addWindows(X509_STORE *store) { + HCERTSTORE system = CertOpenSystemStoreW(0, L"ROOT"); + PCCERT_CONTEXT cert = NULL; + int32_t added = 0; + + if (system == NULL) { + return 0; + } + while ((cert = CertEnumCertificatesInStore(system, cert)) != NULL) { + if ((cert->dwCertEncodingType & X509_ASN_ENCODING) && _addDer(store, cert->pbCertEncoded, (long)cert->cbCertEncoded)) { + added++; + } + } + CertCloseStore(system, 0); + + return added; +} +#endif + + +// One line of the report: what was consulted, and where. +static void _note(char *report, size_t reportSize, const char *what, const char *path) { + size_t used = strlen(report); + + if (used < reportSize) { + snprintf(report + used, reportSize - used, "%s%s %s", (used > 0) ? "\n" : "", what, path); + } +} + + +int32_t netTrustSystem(SSL_CTX *context, const char *dataDir, char *report, size_t reportSize) { + X509_STORE *store = SSL_CTX_get_cert_store(context); + const char *file = getenv("SSL_CERT_FILE"); + const char *dir = getenv("SSL_CERT_DIR"); + char *local = NULL; + size_t length = 0; + int32_t lists = 0; + bool system = false; + + report[0] = 0; + // An operator can name a list outright, and it counts as the machine's. + if (_addFile(store, file)) { + _note(report, reportSize, "system", file); + lists++; + system = true; + } + if (_addDirectory(store, dir)) { + _note(report, reportSize, "system", dir); + lists++; + system = true; + } + if (!system) { +#if defined(_WIN32) + if (_addWindows(store) > 0) { + _note(report, reportSize, "system", "the Windows root store"); + lists++; + } +#elif defined(__APPLE__) + if (_addMac(store) > 0) { + _note(report, reportSize, "system", "the macOS system anchors"); + lists++; + } +#else + size_t x = 0; + + for (x = 0; x < sizeof(LINUX_BUNDLES) / sizeof(LINUX_BUNDLES[0]); x++) { + if (_addFile(store, LINUX_BUNDLES[x])) { + _note(report, reportSize, "system", LINUX_BUNDLES[x]); + lists++; + break; + } + } +#endif + } + // The data directory's own file, which the engine never writes. + if (dataDir != NULL) { + length = strlen(dataDir) + strlen(NET_TRUST_LOCAL_FILE) + 1; + local = malloc(length); + if (local != NULL) { + snprintf(local, length, "%s%s", dataDir, NET_TRUST_LOCAL_FILE); + if (_addFile(store, local)) { + _note(report, reportSize, "local", local); + lists++; + } + free(local); + } + } + // A list that was not there leaves an error on the queue; it is not one. + ERR_clear_error(); + + return lists; +} diff --git a/src/netTrust.h b/src/netTrust.h new file mode 100644 index 000000000..56003a5bc --- /dev/null +++ b/src/netTrust.h @@ -0,0 +1,51 @@ +/* + * + * 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. + * + */ + + +#ifndef NETTRUST_H +#define NETTRUST_H + + +#include <stdint.h> +#include <stdbool.h> +#include <stddef.h> + +#include <openssl/ssl.h> + + +// The name of the file of extra roots in the data directory that the engine never writes, so a +// self-hosted server's own CA survives every run and every update. +#define NET_TRUST_LOCAL_FILE "cacerts.local.pem" +// Room for the report: a few lines of a path each. +#define NET_TRUST_REPORT_SIZE 2048 + + +// Adds the machine's own trusted roots, and the data directory's local file when there is one, +// to a TLS context that already holds the shipped bundle: a connection then verifies when either +// list knows the server's root, so a cabinet whose bundle has gone stale can still reach the +// server that would update it (PLAN.md section 61). report receives what was consulted, one +// "what path" entry per line, for the message a failed verification gives. Returns the number of +// lists that added anything. +int32_t netTrustSystem(SSL_CTX *context, const char *dataDir, char *report, size_t reportSize); + + +#endif // NETTRUST_H diff --git a/src/pack.c b/src/pack.c index a7cedb7e9..ecc5e80a8 100644 --- a/src/pack.c +++ b/src/pack.c @@ -37,6 +37,7 @@ #include "lauxlib.h" #include "lualib.h" +#include "main.h" #include "pack.h" #include "singe.h" #include "util.h" @@ -90,6 +91,7 @@ static bool _isPatchName(const char *database); static bool _isSafeName(const char *name); static char *_keyFor(const char *relative); static int64_t _readMetaInt(sqlite3 *db, const char *schema, const char *key, int64_t fallback); +static bool _requiresFromRuns(WriterT *writer, const char *dataDirBase); static bool _scanEscapes(const EntryT *list); static bool _validateGamesDat(WriterT *writer); static bool _writeBlob(FILE *file, const void *data, size_t bytes); @@ -418,6 +420,157 @@ static int64_t _readMetaInt(sqlite3 *db, const char *schema, const char *key, in } +// REQUIRES from what a game used (PLAN.md section 57). Each games.dat entry that does not declare +// REQUIRES gets the list its runs recorded in their data directory (requires.txt), written into the +// packed games.dat just above its SCRIPT line; an entry that does declare it is only checked, and +// told about anything its runs used that the list leaves out. An entry never run is left alone: no +// REQUIRES means 2D, and a game that has not been run has nothing to say otherwise. +static bool _requiresFromRuns(WriterT *writer, const char *dataDirBase) { + sqlite3_stmt *stmt = NULL; + lua_State *L = NULL; + char *text = NULL; + char *grown = NULL; + char *dir = NULL; + char *path = NULL; + char *record = NULL; + char *line = NULL; + char *quoted = NULL; + char *name = NULL; + const char *script = NULL; + const char *title = NULL; + const char *at = NULL; + const char *lineStart = NULL; + const char *indentEnd = NULL; + size_t length = 0; + size_t bytes = 0; + bool declared = false; + bool listed = false; + bool changed = false; + bool ok = true; + + if (dataDirBase == NULL) { + return true; + } + if (sqlite3_prepare_v2(writer->db, "SELECT data FROM assets WHERE path = ?", -1, &stmt, NULL) != SQLITE_OK) { + return false; + } + sqlite3_bind_text(stmt, 1, VFS_GAMES_DAT, -1, SQLITE_STATIC); + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + return true; + } + length = (size_t)sqlite3_column_bytes(stmt, 0); + text = malloc(length + 1); + if (text == NULL) { + sqlite3_finalize(stmt); + utilSay("!!! Out of memory reading %s.", VFS_GAMES_DAT); + return false; + } + memcpy(text, sqlite3_column_blob(stmt, 0), length); + text[length] = 0; + sqlite3_finalize(stmt); + + L = luaL_newstate(); + if ((luaL_loadbuffer(L, text, length, VFS_GAMES_DAT) != LUA_OK) || (lua_pcall(L, 0, 0, 0) != LUA_OK)) { + // _validateGamesDat says what is wrong with it. + lua_close(L); + free(text); + return true; + } + lua_getglobal(L, "GAMES"); + if (lua_istable(L, -1)) { + lua_pushnil(L); + while (lua_next(L, -2)) { + if (lua_istable(L, -1)) { + lua_getfield(L, -1, "TITLE"); + title = lua_tostring(L, -1) ? lua_tostring(L, -1) : "(untitled)"; + lua_getfield(L, -2, "SCRIPT"); + script = lua_tostring(L, -1); + lua_getfield(L, -3, "REQUIRES"); + declared = lua_istable(L, -1); + if (script != NULL) { + dir = dataDirPath(dataDirBase, script); + path = utilCreateString("%s%s", dir, REQUIRES_RECORD_FILE); + record = utilReadFile(path, &bytes); + free(path); + free(dir); + } + if (record != NULL) { + line = NULL; + for (name = strtok(record, "\r\n"); name != NULL; name = strtok(NULL, "\r\n")) { + if (declared) { + // Declared: only said when a run used something the list leaves out. + listed = false; + lua_pushnil(L); + while (lua_next(L, -2)) { + if ((lua_tostring(L, -1) != NULL) && (strcmp(lua_tostring(L, -1), name) == 0)) { + listed = true; + } + lua_pop(L, 1); + } + if (!listed) { + utilSay("!!! %s: its runs used %s, which its REQUIRES does not list.", title, name); + } + } else { + quoted = utilCreateString("%s%s\"%s\"", (line != NULL) ? line : "", (line != NULL) ? ", " : "", name); + free(line); + line = quoted; + } + } + free(record); + record = NULL; + if (!declared && (line != NULL)) { + // Above the entry's SCRIPT line, indented as it is. + quoted = utilCreateString("\"%s\"", script); + at = strstr(text, quoted); + free(quoted); + if (at == NULL) { + utilSay("!!! %s: could not find its SCRIPT in %s to add REQUIRES = { %s }.", title, VFS_GAMES_DAT, line); + } else { + lineStart = at; + while ((lineStart > text) && (lineStart[-1] != '\n')) { + lineStart--; + } + indentEnd = lineStart; + while ((*indentEnd == ' ') || (*indentEnd == '\t')) { + indentEnd++; + } + grown = utilCreateString("%.*s%.*sREQUIRES = { %s },\n%s", (int)(lineStart - text), text, (int)(indentEnd - lineStart), lineStart, line, lineStart); + free(text); + text = grown; + changed = true; + utilSay(">>> %s: REQUIRES = { %s }, from what its runs used", title, line); + } + } + free(line); + line = NULL; + } + lua_pop(L, 3); + } + lua_pop(L, 1); + } + } + lua_close(L); + + if (changed) { + ok = (sqlite3_prepare_v2(writer->db, "UPDATE assets SET data = ?, size = ? WHERE path = ?", -1, &stmt, NULL) == SQLITE_OK); + if (ok) { + sqlite3_bind_blob(stmt, 1, text, (int)strlen(text), SQLITE_TRANSIENT); + sqlite3_bind_int64(stmt, 2, (sqlite3_int64)strlen(text)); + sqlite3_bind_text(stmt, 3, VFS_GAMES_DAT, -1, SQLITE_STATIC); + ok = (sqlite3_step(stmt) == SQLITE_DONE); + sqlite3_finalize(stmt); + } + if (!ok) { + utilSay("!!! Could not write REQUIRES into %s: %s", VFS_GAMES_DAT, sqlite3_errmsg(writer->db)); + } + } + free(text); + + return ok; +} + + // A packed game may only reach its own contents: ".." in a script or data file is refused, by line. static bool _scanEscapes(const EntryT *list) { const EntryT *entry = NULL; @@ -736,7 +889,7 @@ char *packForbiddenReason(const char *path, bool isPatch) { // Packs a game directory into a new database, or a directory of changed files into a .patch. -bool packGame(const char *directory, const char *database) { +bool packGame(const char *directory, const char *database, const char *dataDirBase) { WriterT writer; char *root = strdup(directory); char *gamesDat = NULL; @@ -775,6 +928,7 @@ bool packGame(const char *directory, const char *database) { free(chunk); free(version); ok = ok && _applyDirectory(&writer, root, !isPatch); + ok = ok && (isPatch || _requiresFromRuns(&writer, dataDirBase)); ok = ok && (isPatch || _validateGamesDat(&writer)); if (ok) { utilSay(">>> Packed %" PRId64 " files (%" PRId64 " bytes) from %s into %s", writer.files, writer.bytes, root, database); diff --git a/src/pack.h b/src/pack.h index 5337bf2b6..53a672861 100644 --- a/src/pack.h +++ b/src/pack.h @@ -29,7 +29,7 @@ char *packForbiddenReason(const char *path, bool isPatch); -bool packGame(const char *directory, const char *database); +bool packGame(const char *directory, const char *database, const char *dataDirBase); bool packIsDatabase(const char *database); bool packPatch(const char *database, const char *source); bool packUnpack(const char *database, const char *directory); diff --git a/src/scene.c b/src/scene.c index 0ebd38665..f5970c165 100644 --- a/src/scene.c +++ b/src/scene.c @@ -6229,6 +6229,32 @@ void sceneSetTonemap(SceneTonemapE tonemap) { } +// Whether this machine can render the scene into 16-bit float targets, which bloom and the other +// post effects want (PLAN.md section 57, POST). +bool sceneSupportsPost(void) { + return (_scene.device != NULL) && rgpuTextureSupportsFormat(_scene.device, SDL_GPU_TEXTUREFORMAT_R16G16B16A16_FLOAT, SDL_GPU_TEXTURETYPE_2D, SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | SDL_GPU_TEXTUREUSAGE_SAMPLER); +} + + +// Whether this machine has a depth format a shadow map can be rendered into and sampled from +// (PLAN.md section 57, SHADOWS). +bool sceneSupportsShadows(void) { + static const SDL_GPUTextureFormat wanted[] = { SDL_GPU_TEXTUREFORMAT_D32_FLOAT, SDL_GPU_TEXTUREFORMAT_D16_UNORM, SDL_GPU_TEXTUREFORMAT_D24_UNORM }; + int32_t x = 0; + + if (_scene.device == NULL) { + return false; + } + for (x = 0; x < (int32_t)SDL_arraysize(wanted); x++) { + if (rgpuTextureSupportsFormat(_scene.device, wanted[x], SDL_GPU_TEXTURETYPE_2D, SDL_GPU_TEXTUREUSAGE_DEPTH_STENCIL_TARGET | SDL_GPU_TEXTUREUSAGE_SAMPLER)) { + return true; + } + } + + return false; +} + + // Overlay coordinates back to a world point: the point on the ray through that pixel at the given // distance from the camera's near plane, using the last rendered frame's camera. Two distances // give a ray for picking. diff --git a/src/scene.h b/src/scene.h index 6fb143090..10ffbc71f 100644 --- a/src/scene.h +++ b/src/scene.h @@ -114,6 +114,8 @@ void sceneSetShadowSize(int32_t size); bool sceneSetSky(const float *rgb, int32_t width, int32_t height); void sceneSetSkyIntensity(float intensity); void sceneSetTonemap(SceneTonemapE tonemap); +bool sceneSupportsPost(void); +bool sceneSupportsShadows(void); Vec3T sceneUnproject(float x, float y, float distance); void sceneUpdateTransforms(void); void sceneUpdateVideo(SceneVideoSourceFn source); diff --git a/src/scriptWatch.c b/src/scriptWatch.c new file mode 100644 index 000000000..1bb6d57fb --- /dev/null +++ b/src/scriptWatch.c @@ -0,0 +1,204 @@ +/* + * + * 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: the watchdog over a running script. The frame loop hears the window close and SIGTERM +// only when the script returns to it, so a script caught in a loop froze the window and ignored +// both. A thread notes how long the script has run; past WATCH_ARM_MS it sets a Lua count hook, +// which runs on the main thread inside the script, pumps the events, and raises an error when a +// quit is pending and the call has run WATCH_STOP_MS. Setting a hook from outside the running thread is how Lua's own interpreter +// stops a script at Ctrl+C; the hook is set and cleared under a lock the main thread also takes +// on entry and return, so it never touches a state that has returned or closed. A script that +// returns sooner never has a hook, and plays exactly as it did. + + +#if !defined(_WIN32) +#include <pthread.h> +#include <signal.h> +#endif + +#include <SDL3/SDL.h> + +#include "../thirdparty/lua/src/lauxlib.h" + +#include "main.h" +#include "scriptWatch.h" +#include "util.h" + + +#define WATCH_ARM_MS 500 // How long a script runs before its events are pumped for it +#define WATCH_HOOK_COUNT 10000 // Instructions between the hook's looks at the events +#define WATCH_POLL_MS 100 // How often the watchdog looks at the script +#define WATCH_STOP_MS 3000 // How long a call runs before a pending quit stops it + +#define WATCH_STOP_MESSAGE "Stopped: the script did not return, and Singe was asked to quit." + + +static void _enter(lua_State *L); +static void _hook(lua_State *L, lua_Debug *ar); +static void _leave(lua_State *L); +static void _stopIfAsked(lua_State *L); +static int _watchThread(void *data); + + +static bool _armed = false; // Under _lock: the hook is set on _state. +static int32_t _depth = 0; // Under _lock: engine-to-script calls under way. +static uint64_t _enteredMS = 0; // Under _lock: when the outermost call began. +static SDL_Mutex *_lock = NULL; +static SDL_AtomicInt _running; +static lua_State *_state = NULL; // Under _lock: the state the outermost call runs on. +static bool _stopping = false; // Main thread only: a stop was raised in this call. +static SDL_Thread *_thread = NULL; + + +static void _enter(lua_State *L) { + SDL_LockMutex(_lock); + if (_depth == 0) { + _state = L; + _enteredMS = SDL_GetTicks(); + } + _depth++; + SDL_UnlockMutex(_lock); +} + + +// The count hook, on the main thread inside the script: the events are pumped so the window +// answers, and a pending quit stops the script. Once stopping it runs at every instruction and +// raises the stop again, so a script that catches it with a pcall of its own meets it at once. +static void _hook(lua_State *L, lua_Debug *ar) { + (void)ar; + if (_stopping) { + luaL_error(L, WATCH_STOP_MESSAGE); + } + SDL_PumpEvents(); + _stopIfAsked(L); +} + + +static void _leave(lua_State *L) { + SDL_LockMutex(_lock); + _depth--; + if (_depth == 0) { + if (_armed) { + lua_sethook(L, NULL, 0, 0); + _armed = false; + } + _state = NULL; + } + SDL_UnlockMutex(_lock); +} + + +// Raises the stop when Singe has been asked to quit (a signal, or the window closed) and the call +// has run WATCH_STOP_MS: a script stuck in a loop has run far longer, and one the engine calls +// on its way out (an onShutdown that saves) gets that long to finish. The close stays in the +// queue for the frame loop, which ends the game from it as it always has. +static void _stopIfAsked(lua_State *L) { + if (SDL_GetTicks() - _enteredMS < WATCH_STOP_MS) { + return; + } + if (mainTerminated() || SDL_HasEvent(SDL_EVENT_QUIT) || SDL_HasEvent(SDL_EVENT_WINDOW_CLOSE_REQUESTED)) { + // Where it was, for whoever wrote it: a stop is not an error, but it is news. + luaL_traceback(L, L, WATCH_STOP_MESSAGE, 0); + utilSay("%s", lua_tostring(L, -1)); + lua_pop(L, 1); + _stopping = true; + lua_sethook(L, _hook, LUA_MASKCOUNT, 1); + luaL_error(L, WATCH_STOP_MESSAGE); + } +} + + +static int _watchThread(void *data) { +#if !defined(_WIN32) + sigset_t all; + + // Signals are the main thread's: one here would count apart from its twin there (timeout + // signals the process and its group), and a second SIGTERM ends Singe at once. + sigfillset(&all); + pthread_sigmask(SIG_BLOCK, &all, NULL); +#endif + (void)data; + while (SDL_GetAtomicInt(&_running)) { + SDL_Delay(WATCH_POLL_MS); + SDL_LockMutex(_lock); + if ((_depth > 0) && !_armed && (SDL_GetTicks() - _enteredMS >= WATCH_ARM_MS)) { + lua_sethook(_state, _hook, LUA_MASKCOUNT, WATCH_HOOK_COUNT); + _armed = true; + } + SDL_UnlockMutex(_lock); + } + + return 0; +} + + +int32_t scriptWatchCall(lua_State *L, int32_t nargs, int32_t nresults, int32_t handler) { + int32_t status = LUA_OK; + int32_t i = 0; + + // Without the watchdog (a tool run, or before the game starts) this is lua_pcall itself. + if (_lock == NULL) { + return lua_pcall(L, nargs, nresults, handler); + } + _enter(L); + status = lua_pcall(L, nargs, nresults, handler); + _leave(L); + if ((status != LUA_OK) && _stopping) { + lua_pop(L, 1); + for (i = 0; i < nresults; i++) { + lua_pushnil(L); + } + status = LUA_OK; + } + if (_depth == 0) { + _stopping = false; + } + + return status; +} + + +void scriptWatchStart(void) { + _lock = SDL_CreateMutex(); + if (_lock == NULL) { + return; + } + SDL_SetAtomicInt(&_running, 1); + _thread = SDL_CreateThread(_watchThread, "scriptWatch", NULL); + if (_thread == NULL) { + SDL_DestroyMutex(_lock); + _lock = NULL; + } +} + + +void scriptWatchStop(void) { + if (_lock == NULL) { + return; + } + SDL_SetAtomicInt(&_running, 0); + SDL_WaitThread(_thread, NULL); + _thread = NULL; + SDL_DestroyMutex(_lock); + _lock = NULL; +} diff --git a/src/scriptWatch.h b/src/scriptWatch.h new file mode 100644 index 000000000..abee49652 --- /dev/null +++ b/src/scriptWatch.h @@ -0,0 +1,43 @@ +/* + * + * 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. + * + */ + + +#ifndef SCRIPTWATCH_H +#define SCRIPTWATCH_H + + +#include <stdint.h> +#include <stdbool.h> + +#include "../thirdparty/lua/src/lua.h" + + +// Runs a script function as lua_pcall does, watched: a script that runs this long without +// returning to the engine has its events pumped for it, and stops when Singe is asked to quit (a +// signal, or the window closed). A stop reads as a return with its results nil, so the frame loop +// goes on to end the game as it would have; any other error is the caller's, as lua_pcall's is. +int32_t scriptWatchCall(lua_State *L, int32_t nargs, int32_t nresults, int32_t handler); +void scriptWatchStart(void); +void scriptWatchStop(void); + + +#endif // SCRIPTWATCH_H diff --git a/src/singe.c b/src/singe.c index cb793844d..bddbc65d5 100644 --- a/src/singe.c +++ b/src/singe.c @@ -65,12 +65,15 @@ int luaopen_cjson(lua_State *L); int luaopen_lpeg(lua_State *L); // There is no header for ssl.config binding. Make our own. LSEC_API int luaopen_ssl_config(lua_State *L); +// Nor for reaching the SSL_CTX inside a LuaSec context, which netTrustSystem needs. +SSL_CTX *lsec_checkcontext(lua_State *L, int idx); #include "collide.h" #include "decode.h" #include "main.h" #include "midi.h" #include "midiIo.h" +#include "netTrust.h" #include "pack.h" #include "util.h" #include "frameFile.h" @@ -79,6 +82,7 @@ LSEC_API int luaopen_ssl_config(lua_State *L); #include "scene.h" #include "render.h" #include "scheduler.h" +#include "scriptWatch.h" #include "stats.h" #include "hdr.h" #include "ktx2.h" @@ -773,6 +777,11 @@ typedef struct GlobalS { FontT *fontList; FontT *fontCurrent; MappingT controlMappings[INPUT_COUNT]; + uint32_t capabilitiesUsed; // CapabilityE bits this game has used so far + int32_t machineTier; // Measured performance class from machine.cfg, or TIER_UNMEASURED + bool sceneMeasuring; // sceneMeasure: each frame waits for the scene to finish and adds its time + uint64_t sceneMeasureNS; + uint64_t sceneMeasureFrames; ConfigT *conf; // Local copy of command line options } GlobalT; @@ -817,6 +826,21 @@ typedef struct PerfCallsS { static GlobalT _global; + +// The capability names games.dat's REQUIRES and singeCapabilities use, one per CapabilityE bit. +typedef struct CapabilityNameS { + uint32_t bit; + const char *name; +} CapabilityNameT; + +static const CapabilityNameT CAPABILITY_NAMES[] = { + { CAPABILITY_SCENE, "SCENE" }, + { CAPABILITY_GUI, "GUI" }, + { CAPABILITY_SHADOWS, "SHADOWS" }, + { CAPABILITY_POST, "POST" }, + { CAPABILITY_SKIN, "SKIN" }, + { CAPABILITY_MORPH, "MORPH" } +}; static PerfT _perf; static PerfCallsT _perfCalls; static MIX_Track *_effectTracks[EFFECT_TRACKS]; @@ -900,6 +924,8 @@ static void _bezelLoad(void); static bool _bezelReadCutout(const char *path, int32_t width, int32_t height, SDL_FRect *cutout); static ConfigT *_buildConfFromTable(lua_State *L, const ConfigT *base, const char *method); static void _callLua(const char *func, const char *sig, ...); +static uint32_t _capabilitiesFromList(lua_State *L, int32_t index, const char *method); +static void _capabilityUsed(uint32_t bit); static bool _chordChanged(ChordT *chord, bool down, int32_t scancode); static bool _chordsUpdate(bool down, int32_t scancode); static bool _clipLine(int32_t *x1, int32_t *y1, int32_t *x2, int32_t *y2); @@ -1383,6 +1409,7 @@ static int32_t apiNavPath(lua_State *L); static int32_t apiNavRandomPoint(lua_State *L); static int32_t apiNavRaycast(lua_State *L); static int32_t apiNavSave(lua_State *L); +static int32_t apiNetTrustSystem(lua_State *L); static int32_t apiNodeDelete(lua_State *L); static int32_t apiNodeFind(lua_State *L); static int32_t apiNodeGetChildren(lua_State *L); @@ -1473,6 +1500,7 @@ static int32_t apiSaveSetAll(lua_State *L); static int32_t apiSceneEnable(lua_State *L); static int32_t apiSceneGetSize(lua_State *L); static int32_t apiSceneGetStats(lua_State *L); +static int32_t apiSceneMeasure(lua_State *L); static int32_t apiSceneProbeGui(lua_State *L); static int32_t apiSceneProject(lua_State *L); static int32_t apiSceneSetAmbient(lua_State *L); @@ -1502,12 +1530,14 @@ static int32_t apiSetOverlayFullAlpha(lua_State *L); static int32_t apiSetOverlayLinearScale(lua_State *L); static int32_t apiSetOverlayOnTop(lua_State *L); static int32_t apiSetOverlayOpacity(lua_State *L); +static int32_t apiSingeCapabilities(lua_State *L); static int32_t apiSingeGetAudioCalibration(lua_State *L); static int32_t apiSingeGetAudioDelay(lua_State *L); static int32_t apiSingeGetAudioLatency(lua_State *L); static int32_t apiSingeGetDataPath(lua_State *L); static int32_t apiSingeGetSystemInfo(lua_State *L); static int32_t apiSingeSaveGeometry(lua_State *L); +static int32_t apiSingeSaveTier(lua_State *L); static int32_t apiSingeGetGameId(lua_State *L); static int32_t apiSingeGetHeight(lua_State *L); static int32_t apiSingeGetPauseFlag(lua_State *L); @@ -2347,6 +2377,8 @@ static ConfigT *_buildConfFromTable(lua_State *L, const ConfigT *base, const cha c->videoFile = NULL; free(c->dataDir); c->dataDir = NULL; + c->requires = 0; + c->tier = 0; // Update with data in the table on the top of the Lua stack. lua_pushnil(L); @@ -2436,6 +2468,16 @@ static ConfigT *_buildConfFromTable(lua_State *L, const ConfigT *base, const cha c->audioSuffix = strdup(valueString); } else if (strcmp(confKey, "LEGACY_SPRITE_ARGS") == 0) { c->legacySpriteArgs = valueBoolean; + } else if (strcmp(confKey, "REQUIRES") == 0) { + if (!lua_istable(L, -1)) { + _confFail(L, method, "REQUIRES must be a list of capability names."); + } + c->requires = _capabilitiesFromList(L, -1, method); + } else if (strcmp(confKey, "TIER") == 0) { + if ((valueNumber < 0) || (valueNumber > TIER_MAX)) { + _confFail(L, method, "TIER must be 0 to %d.", TIER_MAX); + } + c->tier = (int32_t)valueNumber; } else if (strcmp(confKey, "CANVAS_X") == 0) { c->canvasWidth = (int32_t)valueNumber; } else if (strcmp(confKey, "CANVAS_Y") == 0) { @@ -2535,7 +2577,7 @@ static void _callLua(const char *func, const char *sig, ...) { // Do the call. Script errors are fatal, like every other error in Singe. nres = (int32_t)strlen(sig); - if (lua_pcall(_global.luaContext, narg, nres, handler) != 0) { + if (scriptWatchCall(_global.luaContext, narg, nres, handler) != 0) { utilDie("Error executing function '%s': %s", func, lua_tostring(_global.luaContext, -1)); } @@ -2563,6 +2605,73 @@ static void _callLua(const char *func, const char *sig, ...) { } +// A REQUIRES list from games.dat: capability names as strings, turned into CapabilityE bits. A +// name that is not a capability is an error, as any other bad games.dat value is. +static uint32_t _capabilitiesFromList(lua_State *L, int32_t index, const char *method) { + const char *name = NULL; + uint32_t bits = 0; + int32_t at = lua_absindex(L, index); + size_t x = 0; + bool found = false; + + lua_pushnil(L); + while (lua_next(L, at)) { + name = lua_tostring(L, -1); + found = false; + for (x = 0; (name != NULL) && (x < SDL_arraysize(CAPABILITY_NAMES)); x++) { + if (strcmp(name, CAPABILITY_NAMES[x].name) == 0) { + bits |= CAPABILITY_NAMES[x].bit; + found = true; + } + } + if (!found) { + _confFail(L, method, "REQUIRES names %s, which is not a capability (SCENE, GUI, SHADOWS, POST, SKIN, MORPH).", (name != NULL) ? name : "something that is not a name"); + } + lua_pop(L, 1); + } + + return bits; +} + + +// A game has used a capability. The first use is recorded in the game's data directory, where +// --pack reads it to write REQUIRES, and a game that did not declare it is told so in the trace: +// a hand-kept list is wrong within two releases, so the engine is what knows (PLAN.md section 57). +static void _capabilityUsed(uint32_t bit) { + char *path = NULL; + FILE *out = NULL; + size_t x = 0; + + if ((_global.capabilitiesUsed & bit) != 0) { + return; + } + _global.capabilitiesUsed |= bit; + // The engine's own scripts (the menu, the service tools) are not games: nothing packs them. + if ((_global.conf->scriptFile != NULL) && (strncmp(_global.conf->scriptFile, VFS_ENGINE_DIRECTORY, strlen(VFS_ENGINE_DIRECTORY)) == 0) && ((_global.conf->scriptFile[strlen(VFS_ENGINE_DIRECTORY)] == '/') || (_global.conf->scriptFile[strlen(VFS_ENGINE_DIRECTORY)] == '\\'))) { + return; + } + for (x = 0; x < SDL_arraysize(CAPABILITY_NAMES); x++) { + if ((CAPABILITY_NAMES[x].bit == bit) && ((_global.conf->requires & bit) == 0)) { + utilSay("Warning: this game uses %s, which its games.dat does not list in REQUIRES.", CAPABILITY_NAMES[x].name); + } + } + if (_global.conf->dataDir == NULL) { + return; + } + path = utilCreateString("%s%s", _global.conf->dataDir, REQUIRES_RECORD_FILE); + out = fopen(path, "w"); + if (out != NULL) { + for (x = 0; x < SDL_arraysize(CAPABILITY_NAMES); x++) { + if ((_global.capabilitiesUsed & CAPABILITY_NAMES[x].bit) != 0) { + fprintf(out, "%s\n", CAPABILITY_NAMES[x].name); + } + } + fclose(out); + } + free(path); +} + + // Whether a press or a release changes a chord: a press that completes it, or the release of one of // its buttons while it is down. The chord is updated to match. static bool _chordChanged(ChordT *chord, bool down, int32_t scancode) { @@ -3715,7 +3824,7 @@ static void _guiEvent(int32_t gui, int32_t document, const char *id, const char lua_pushstring(L, id); lua_pushstring(L, event); lua_pushstring(L, value); - if (lua_pcall(L, 5, 0, handler) != LUA_OK) { + if (scriptWatchCall(L, 5, 0, handler) != LUA_OK) { utilDie("Error in GUI handler for \"%s\" %s: %s", id, event, lua_tostring(L, -1)); } lua_settop(L, handler - 1); @@ -4548,7 +4657,12 @@ static void _loadMachineSettings(void) { int32_t value = 0; free(path); + _global.machineTier = TIER_UNMEASURED; if (data != NULL) { + at = strstr(data, "tier = "); + if ((at != NULL) && (sscanf(at, "tier = %d", &value) == 1) && (value >= 0) && (value <= TIER_MAX)) { + _global.machineTier = value; + } at = strstr(data, "delay = "); if ((at != NULL) && (sscanf(at, "delay = %d", &value) == 1) && (value >= -VIDEO_AUDIO_DELAY_MAX) && (value <= VIDEO_AUDIO_DELAY_MAX)) { delay = value; @@ -6829,6 +6943,7 @@ static void _registerApi(lua_State *L) { lua_register(L, "navRandomPoint", apiNavRandomPoint); // 3.00 lua_register(L, "navRaycast", apiNavRaycast); // 3.00 lua_register(L, "navSave", apiNavSave); // 3.00 + lua_register(L, "netTrustSystem", apiNetTrustSystem); // 3.00 lua_register(L, "nodeDelete", apiNodeDelete); // 3.00 lua_register(L, "nodeFind", apiNodeFind); // 3.00 lua_register(L, "nodeGetChildren", apiNodeGetChildren); // 3.00 @@ -6918,6 +7033,7 @@ static void _registerApi(lua_State *L) { lua_register(L, "sceneEnable", apiSceneEnable); // 3.00 lua_register(L, "sceneGetSize", apiSceneGetSize); // 3.00 lua_register(L, "sceneGetStats", apiSceneGetStats); // 3.00 + lua_register(L, "sceneMeasure", apiSceneMeasure); // 3.00 lua_register(L, "sceneProbeGui", apiSceneProbeGui); // 3.00 lua_register(L, "sceneProject", apiSceneProject); // 3.00 lua_register(L, "sceneSetAmbient", apiSceneSetAmbient); // 3.00 @@ -6951,6 +7067,7 @@ static void _registerApi(lua_State *L) { lua_register(L, "singeGetAudioCalibration", apiSingeGetAudioCalibration); // 3.00 lua_register(L, "singeGetAudioDelay", apiSingeGetAudioDelay); // 3.00 lua_register(L, "singeGetAudioLatency", apiSingeGetAudioLatency); // 3.00 + lua_register(L, "singeCapabilities", apiSingeCapabilities); // 3.00 lua_register(L, "singeGetDataPath", apiSingeGetDataPath); // 2.00 lua_register(L, "singeGetGameId", apiSingeGetGameId); // 3.00 lua_register(L, "singeGetHeight", apiSingeGetHeight); // 1.xx @@ -6965,6 +7082,7 @@ static void _registerApi(lua_State *L) { lua_register(L, "singeRelaunch", apiSingeRelaunch); // 3.00 lua_register(L, "singeReload", apiSingeReload); // 3.00 lua_register(L, "singeSaveGeometry", apiSingeSaveGeometry); // 3.00 + lua_register(L, "singeSaveTier", apiSingeSaveTier); // 3.00 lua_register(L, "singeScreenshot", apiSingeScreenshot); // 1.xx lua_register(L, "singeSetAudioCalibration", apiSingeSetAudioCalibration); // 3.00 lua_register(L, "singeSetAudioDelay", apiSingeSetAudioDelay); // 3.00 @@ -7393,7 +7511,7 @@ static void _rmlEscape(char *out, size_t size, const char *text) { static void _runScript(bool fatal) { _progTrace("Running %s", _global.conf->scriptFile); lua_pushcfunction(_global.luaContext, _luaTraceback); - if (_luaLoadFile(_global.luaContext, _global.conf->scriptFile, NULL, _global.conf->reload) || lua_pcall(_global.luaContext, 0, 0, -2)) { + if (_luaLoadFile(_global.luaContext, _global.conf->scriptFile, NULL, _global.conf->reload) || scriptWatchCall(_global.luaContext, 0, 0, -2)) { if (fatal) { utilDie("Error running script: %s", lua_tostring(_global.luaContext, -1)); } @@ -7492,6 +7610,9 @@ static void _saveMachineSettings(int32_t milliseconds) { fprintf(out, "shiftx = %d\n", _global.videoShiftX); fprintf(out, "shifty = %d\n", _global.videoShiftY); fprintf(out, "rotate = %d\n", _global.videoRotate); + if (_global.machineTier != TIER_UNMEASURED) { + fprintf(out, "tier = %d\n", _global.machineTier); + } fclose(out); } else { utilSay("Unable to write %s", path); @@ -7642,7 +7763,7 @@ static void _scheduleFired(void *context, int32_t handle, SchedulerKindE kind, d handler = lua_gettop(L) - 1; lua_pushnumber(L, value); lua_pushnumber(L, progress); - if (lua_pcall(L, 2, 0, handler) != LUA_OK) { + if (scriptWatchCall(L, 2, 0, handler) != LUA_OK) { utilDie("Error in a tween: %s", lua_tostring(L, -1)); } lua_remove(L, handler); @@ -7661,12 +7782,12 @@ static void _scheduleFired(void *context, int32_t handle, SchedulerKindE kind, d if (kind == SCHEDULER_TWEEN) { lua_pushnumber(L, value); lua_pushnumber(L, progress); - if (lua_pcall(L, 2, 0, handler) != LUA_OK) { + if (scriptWatchCall(L, 2, 0, handler) != LUA_OK) { utilDie("Error in a tween: %s", lua_tostring(L, -1)); } } else { lua_pushinteger(L, handle); - if (lua_pcall(L, 1, 0, handler) != LUA_OK) { + if (scriptWatchCall(L, 1, 0, handler) != LUA_OK) { utilDie("Error in a timer: %s", lua_tostring(L, -1)); } } @@ -10912,6 +11033,7 @@ static int32_t apiGuiNew(lua_State *L) { if ((width <= 0) || (height <= 0)) { _luaDie(L, "guiNew", "The size must be positive."); } + _capabilityUsed(CAPABILITY_GUI); gui = guiNew(width, height); if (gui < 0) { _luaDie(L, "guiNew", "No GUI available (is a GPU device present, and are fewer than %d in use?).", GUI_MAX_CONTEXTS); @@ -11263,6 +11385,9 @@ static int32_t apiLightSetShadow(lua_State *L) { _argCheck(L, "lightSetShadow", 2, 2); node = _argNode(L, "lightSetShadow", 1); + if (_argBoolean(L, "lightSetShadow", 2)) { + _capabilityUsed(CAPABILITY_SHADOWS); + } if (!lightSetShadow(node, _argBoolean(L, "lightSetShadow", 2))) { _luaDie(L, "lightSetShadow", "Node %d is not a light.", node); } @@ -12052,6 +12177,9 @@ static int32_t apiModelInstance(lua_State *L) { if (lua_gettop(L) >= 2) { parent = _argNode(L, "modelInstance", 2); } + if (modelIsSkinned(model)) { + _capabilityUsed(CAPABILITY_SKIN); + } root = modelInstance(model, parent); if (root < 0) { _luaDie(L, "modelInstance", "Unable to instance model %d.", model); @@ -12644,6 +12772,26 @@ static int32_t apiNavSave(lua_State *L) { } +// lists, report = netTrustSystem(context) Adds the machine's own trusted roots, and the data +// directory's cacerts.local.pem when there is one, to a LuaSec client context that already holds +// the shipped bundle, so a connection verifies when either knows the server's root. lists is how +// many were added; report names what was consulted, one "what path" per line (PLAN.md section 61). +static int32_t apiNetTrustSystem(lua_State *L) { + SSL_CTX *context = NULL; + int32_t lists = 0; + char report[NET_TRUST_REPORT_SIZE]; + + _argCheck(L, "netTrustSystem", 1, 1); + context = lsec_checkcontext(L, 1); + lists = netTrustSystem(context, _global.conf->dataDir, report, sizeof(report)); + _luaTrace(L, "netTrustSystem", "%d", lists); + lua_pushinteger(L, lists); + lua_pushstring(L, report); + + return 2; +} + + // nodeDelete(node): the node and everything under it static int32_t apiNodeDelete(lua_State *L) { int32_t node; @@ -12898,6 +13046,7 @@ static int32_t apiNodeSetMorph(lua_State *L) { _argCheck(L, "nodeSetMorph", 3, 3); node = _argNode(L, "nodeSetMorph", 1); target = _argMorph(L, "nodeSetMorph", node, 2); + _capabilityUsed(CAPABILITY_MORPH); nodeSetMorphWeight(node, target, (float)_argNumber(L, "nodeSetMorph", 3)); return 0; } @@ -13935,6 +14084,9 @@ static int32_t apiSceneEnable(lua_State *L) { if (!sceneEnable(enabled)) { _luaDie(L, "sceneEnable", "3D is not available on this machine."); } + if (enabled) { + _capabilityUsed(CAPABILITY_SCENE); + } _global.refreshDisplay = true; _luaTrace(L, "sceneEnable", "%s", enabled ? "on" : "off"); return 0; @@ -13969,6 +14121,35 @@ static int32_t apiSceneGetStats(lua_State *L) { } +// sceneMeasure(true) starts timing the scene: every frame after it waits for the GPU to finish the +// scene and adds that time up, so what is measured is the work and not the display's pacing. +// sceneMeasure(false) stops and returns the milliseconds a frame averaged, or nil when no frame was +// drawn. The menu times its own backdrop this way to find the machine's tier (PLAN.md section 57); +// the wait costs frame rate, so it is for a few seconds, not for a whole game. +static int32_t apiSceneMeasure(lua_State *L) { + bool on = false; + + _argCheck(L, "sceneMeasure", 1, 1); + on = _argBoolean(L, "sceneMeasure", 1); + if (on) { + _global.sceneMeasuring = true; + _global.sceneMeasureNS = 0; + _global.sceneMeasureFrames = 0; + _luaTrace(L, "sceneMeasure", "on"); + return 0; + } + _global.sceneMeasuring = false; + if (_global.sceneMeasureFrames == 0) { + lua_pushnil(L); + } else { + lua_pushnumber(L, (lua_Number)_global.sceneMeasureNS / (lua_Number)_global.sceneMeasureFrames / (lua_Number)SDL_NS_PER_MS); + } + _luaTrace(L, "sceneMeasure", "off after %" PRIu64 " frames", _global.sceneMeasureFrames); + + return 1; +} + + // x, y, depth, inFront = sceneProject(wx, wy, wz): a world point in overlay coordinates // gui, x, y = sceneProbeGui(sx, sy): the GUI on the 3D surface under an overlay point and the pixel of it // there (a light gun aimed at a screen in the scene); nil when the point is off every such surface @@ -14042,6 +14223,9 @@ static int32_t apiSceneSetBackground(lua_State *L) { // sceneSetBloom(threshold, strength): the glow of everything brighter than the threshold; strength 0 for none static int32_t apiSceneSetBloom(lua_State *L) { _argCheck(L, "sceneSetBloom", 2, 2); + if (_argNumber(L, "sceneSetBloom", 2) > 0.0) { + _capabilityUsed(CAPABILITY_POST); + } sceneSetBloom((float)_argNumber(L, "sceneSetBloom", 1), (float)_argNumber(L, "sceneSetBloom", 2)); return 0; } @@ -14344,6 +14528,38 @@ static int32_t apiSetOverlayOpacity(lua_State *L) { } +// capabilities = singeCapabilities() What this machine can run: a table of the capability names +// (SCENE, GUI, SHADOWS, POST, SKIN, MORPH) each true or false, and tier, the measured performance +// class from 0 to 3, or nil before the menu has measured it. A script asks so it can degrade itself +// rather than be refused (PLAN.md section 57). +static int32_t apiSingeCapabilities(lua_State *L) { + bool scene = sceneAvailable(); + + _argCheck(L, "singeCapabilities", 0, 0); + lua_newtable(L); + lua_pushboolean(L, scene); + lua_setfield(L, -2, "SCENE"); + lua_pushboolean(L, guiAvailable()); + lua_setfield(L, -2, "GUI"); + lua_pushboolean(L, sceneSupportsShadows()); + lua_setfield(L, -2, "SHADOWS"); + lua_pushboolean(L, sceneSupportsPost()); + lua_setfield(L, -2, "POST"); + // Skinning and morph targets run in the scene's own shaders: where there is a scene, there are both. + lua_pushboolean(L, scene); + lua_setfield(L, -2, "SKIN"); + lua_pushboolean(L, scene); + lua_setfield(L, -2, "MORPH"); + if (_global.machineTier != TIER_UNMEASURED) { + lua_pushinteger(L, _global.machineTier); + lua_setfield(L, -2, "tier"); + } + _luaTrace(L, "singeCapabilities", "tier %d", _global.machineTier); + + return 1; +} + + // milliseconds = singeGetAudioCalibration() The per-machine value saved by the menu's calibration screen. static int32_t apiSingeGetAudioCalibration(lua_State *L) { int32_t value = videoGetAudioCalibration(); @@ -14428,6 +14644,24 @@ static int32_t apiSingeSaveGeometry(lua_State *L) { } +// singeSaveTier(tier) Keeps this machine's measured performance class, 0 to 3, beside the audio +// delay and the geometry. The menu measures it from its own backdrop; nothing asks the player. +static int32_t apiSingeSaveTier(lua_State *L) { + int32_t tier = 0; + + _argCheck(L, "singeSaveTier", 1, 1); + tier = (int32_t)_argInteger(L, "singeSaveTier", 1); + if ((tier < 0) || (tier > TIER_MAX)) { + _luaDie(L, "singeSaveTier", "The tier must be 0 to %d.", TIER_MAX); + } + _global.machineTier = tier; + _saveMachineSettings(videoGetAudioCalibration()); + _luaTrace(L, "singeSaveTier", "%d", tier); + + return 0; +} + + // path = singeGetDataPath() static int32_t apiSingeGetDataPath(lua_State *L) { _luaTrace(L, "singeGetDataPath", "%s", _global.conf->dataDir); @@ -14532,7 +14766,7 @@ static int32_t apiSingePack(lua_State *L) { _argCheck(L, "singePack", 2, 2); directory = _argString(L, "singePack", 1); database = _argString(L, "singePack", 2); - ok = packGame(directory, database); + ok = packGame(directory, database, _global.conf->dataDirBase); _luaTrace(L, "singePack", "%s %s %s", directory, database, ok ? "ok" : "failed"); lua_pushboolean(L, ok); @@ -17624,6 +17858,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co // Start script memset(&_perf, 0, sizeof(_perf)); _perf.scriptStartNS = SDL_GetTicksNS(); + scriptWatchStart(); _runScript(true); _perf.windowStartNS = SDL_GetTicksNS(); @@ -18040,6 +18275,12 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co sceneTexture = sceneRender(); _perfSync(); _perf.sceneNS += SDL_GetTicksNS() - stageStartNS; + if (_global.sceneMeasuring && (sceneTexture != NULL)) { + // The GPU's share as well: a frame paced by the display says nothing about the machine. + renderFinish(_global.device); + _global.sceneMeasureNS += SDL_GetTicksNS() - stageStartNS; + _global.sceneMeasureFrames++; + } _updateSounds(); if (sceneTexture != NULL) { SDL_RenderTexture(_global.renderer, sceneTexture, NULL, &_global.videoRect); @@ -18172,6 +18413,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co // Stop Lua _progTrace("Stopping Lua"); + scriptWatchStop(); guiDetachLua(); lua_close(_global.luaContext); _global.luaContext = NULL; diff --git a/src/singe.h b/src/singe.h index 4b41ae02d..5a7c28e01 100644 --- a/src/singe.h +++ b/src/singe.h @@ -76,6 +76,23 @@ typedef enum GivenE { GIVEN_ROTATE = 1 << 11 } GivenE; +// What a game can need that not every machine has (PLAN.md section 57). Missing one means the game +// will not start; a game's games.dat names the ones it uses in REQUIRES, and no REQUIRES means 2D. +typedef enum CapabilityE { + CAPABILITY_SCENE = 1 << 0, // The 3D scene + CAPABILITY_GUI = 1 << 1, // GUI documents + CAPABILITY_SHADOWS = 1 << 2, // Shadow maps + CAPABILITY_POST = 1 << 3, // Float render targets: bloom and the other post effects + CAPABILITY_SKIN = 1 << 4, // Skinned models + CAPABILITY_MORPH = 1 << 5 // Morph targets +} CapabilityE; + +// The measured performance class of this machine (machine.cfg), before it has been measured. +#define TIER_UNMEASURED (-1) +#define TIER_MAX 3 +// The capabilities a game has used, one name a line, in its data directory: --pack reads it. +#define REQUIRES_RECORD_FILE "requires.txt" + typedef struct ConfigS { char *videoFile; char *scriptFile; @@ -135,6 +152,8 @@ typedef struct ConfigS { int32_t shiftY; // --shifty: per cent of half the logical height the picture moves down int32_t rotate; // --rotate: clockwise presentation rotation, 0, 90, 180 or 270 degrees char *gameId; // GAME_ID from games.dat: the master service's stable id for this game + uint32_t requires; // REQUIRES from games.dat: the CapabilityE bits the game declares + int32_t tier; // TIER from games.dat: the performance class the game wants, 0 for any int32_t xResolution; int32_t yResolution; int32_t canvasWidth; // World size when there is no disc diff --git a/src/util.c b/src/util.c index eaa475d66..961e2a98a 100644 --- a/src/util.c +++ b/src/util.c @@ -48,6 +48,9 @@ #include "util.h" +#define PRINT_LINE_BUFFER 1024 // A log line this long or shorter is formatted without the heap + + static bool _consoleEnabled = true; static bool _outputHappened = false; static FILE *_utilTraceFile = NULL; @@ -81,15 +84,42 @@ static bool _ensureDirectory(const char *path, const mode_t mode) { } +// The line and its newline go out in one call: stdio holds the stream's lock for a call, so a +// line another thread prints (the video thread's, say) lands before or after it, never inside it. static void _printLine(FILE *stream, const char *fmt, va_list args) { - // No caller passes a null format, but glibc's fortified vfprintf is inlined here and the + char buffer[PRINT_LINE_BUFFER]; + char *line = buffer; + va_list again; + int length = 0; + + // No caller passes a null format, but glibc's fortified vsnprintf is inlined here and the // compiler cannot see that, so say it plainly rather than carry the warning. if (fmt == NULL) { return; } - vfprintf(stream, fmt, args); - fputc('\n', stream); + va_copy(again, args); + length = vsnprintf(buffer, sizeof(buffer) - 1, fmt, args); + if (length < 0) { + va_end(again); + return; + } + // Room for the newline too; a longer line is formatted again into one of its own size. + if ((size_t)length >= sizeof(buffer) - 1) { + line = malloc((size_t)length + 2); + if (line == NULL) { + va_end(again); + return; + } + vsnprintf(line, (size_t)length + 1, fmt, again); + } + va_end(again); + line[length] = '\n'; + line[length + 1] = 0; + fputs(line, stream); fflush(stream); + if (line != buffer) { + free(line); + } } diff --git a/testScripts/ports/compare.sh b/testScripts/ports/compare.sh index 22169bda6..03f86c6e1 100755 --- a/testScripts/ports/compare.sh +++ b/testScripts/ports/compare.sh @@ -97,13 +97,18 @@ case "$family" in # An American Laser Games title from the Hypseus library: the same layout as the # map-mode ones, its own drivers. library=${SINGE_LIBRARY:-$HOME/claude/singetest/hypseus} - if [[ -d "$library/$game/Script" ]]; then + # The Hypseus layout wins when singe/ holds the description, even beside a Script/ of the + # game's own files (the one-player Johnny Rock HD edition keeps both). + hypseusDesc=$(find -L "$library/$game/singe" -mindepth 2 -maxdepth 2 -name "*.forge" 2>/dev/null | head -1) + if [[ -z "$hypseusDesc" && -d "$library/$game/Script" ]]; then # The library's own copy (Script/, Cfg/, Video/ with the framefile beside the video; # Platoon keeps its Video/ under Script/): the script is the description's namesake, # the video the framefile in Video/. desc=$(ls "$library/$game/Script"/*.forge 2>/dev/null | head -1) export PORT_SCRIPT=Script/$(basename "${desc%.forge}").singe video=$(ls "$library/$game/Video"/*.txt "$library/$game/Script/Video"/*.txt 2>/dev/null | head -1) + # No framefile: the video itself, when the title keeps one there. + [[ -n "$video" ]] || video=$(ls "$library/$game/Video"/*.m2v "$library/$game/Video"/*.mp4 "$library/$game/Video"/*.mkv "$library/$game/Script/Video"/*.m2v 2>/dev/null | head -1) else desc=$(find -L "$library/$game/singe" -mindepth 2 -maxdepth 2 -name "*.forge" 2>/dev/null | head -1) if [[ -n "$desc" ]]; then @@ -141,19 +146,35 @@ case "$family" in exit 2 ;; esac +if [[ -z "${video:-}" ]]; then + echo "compare.sh: no video for $family $game in the library; nothing to compare" >&2 + exit 2 +fi export SINGE_LIBRARY=$library PORT_GAME=$game SDL_VIDEODRIVER=${SDL_VIDEODRIVER:-offscreen} SDL_AUDIO_DRIVER=dummy trace() { local name=$1 script=$2 work work=$(mktemp -d "$run/$name.XXXXXX") - ( cd "$run" && timeout 3600 "$binary" -w -p -k --deterministic -d "$work" -v "$video" "$script" ) > "$work/run.log" 2>&1 - grep -a '^TRACE' "$work/run.log" | grep -v 'state 2 from' > "$work/trace" + local pass passes=1 + # PORT_TWICE=1 runs each side twice in the same data directory (the original's own files are + # put back only after both), so the second run shows what the first one saved: the + # Persistence row of FORGE.md 14.18. The trace is both runs', the drive told which it is. + [[ "${PORT_TWICE:-}" == "1" ]] && passes=2 + : > "$work/trace" + for (( pass = 1; pass <= passes; pass++ )); do + # An hour a run; a run spinning in Lua never sees the quit SIGTERM asks for, so -k kills it. + ( cd "$run" && PORT_PASS=$pass timeout -k 30 3600 "$binary" -w -p -k --deterministic -d "$work" -v "$video" "$script" ) > "$work/run$pass.log" 2>&1 + (( pass > 1 )) && echo "TRACE run $pass" >> "$work/trace" + grep -a '^TRACE' "$work/run$pass.log" | grep -v 'state 2 from' >> "$work/trace" + done + cp "$work/run1.log" "$work/run.log" grep -a 'rror\|Author:' "$work/run.log" | head -3 >&2 # The screenshots a drive took (PORT_SHOTS=1) land in the run's data directory; with # SINGE_SHOTS set they are gathered there, named for the run. if [[ -n "${SINGE_SHOTS:-}" ]]; then mkdir -p "$SINGE_SHOTS" - for png in "$work"/*.png; do - [[ -f "$png" ]] && cp "$png" "$SINGE_SHOTS/$name-$(basename "$png")" + # The engine saves under the game's own folder in the data directory, wherever that is. + find "$work" -name "*.png" -print0 | while IFS= read -r -d '' png; do + cp "$png" "$SINGE_SHOTS/$name-$(basename "$png")" done fi echo "$work/trace" @@ -161,6 +182,10 @@ trace() { # The originals autosave into their own Cfg folder as they play, which would give the port's # snapshot and the next run different saves to read: the folder is put back as it was. cfgDir="" +# One comparison of a title at a time: two would each keep the original's files, then put them +# back over each other. A second waits for the first. +exec 9> "${TMPDIR:-/tmp}/compare-$family-${game//\//_}.lock" +flock 9 if [[ -n "${PORT_SCRIPT:-}" ]]; then cfgDir=$library/$game/$(dirname "$PORT_SCRIPT")/Cfg [[ -d "$cfgDir" ]] || cfgDir=$library/$game/Cfg @@ -170,10 +195,11 @@ if [[ -n "$cfgDir" ]]; then cfgKeep=$(mktemp -d "$run/cfg.XXXXXX") cp -r "$cfgDir/." "$cfgKeep/" fi -# An American Laser Games title keeps its dips and board in a .cfg beside its script, which -# the original writes at every board: kept and put back the same way. +# A title that keeps its dips and board in a .cfg beside its script (the gun games, the map-mode +# games) has the original write it at every board: kept and put back the same way, whatever the +# family, or a run leaves its scores in the library for the next description to snapshot. cfgFiles="" -if [[ "$family" == "alg" ]]; then +if [[ -n "${PORT_SCRIPT:-}" ]]; then cfgFiles=$(ls "$library/$game/$(dirname "$PORT_SCRIPT")"/*.cfg 2>/dev/null) if [[ -n "$cfgFiles" ]]; then cfgKeepFiles=$(mktemp -d "$run/cfgfiles.XXXXXX") diff --git a/testScripts/ports/karisDrive.singe b/testScripts/ports/karisDrive.singe index 1054b387b..9cc21b55f 100644 --- a/testScripts/ports/karisDrive.singe +++ b/testScripts/ports/karisDrive.singe @@ -8,6 +8,7 @@ -- The trace both print: every disc seek, and every change of the framework's screen, step, -- level, scene, move, score, and lives, in the framework's numbers. KARIS_SEED = 20260914 +AUTHOR_SEED = KARIS_SEED -- The port's runtime seeds its generator from this, as the original's os.time does. local lastLine = nil local pressed = nil -- The switch held down this frame, released the next. @@ -23,6 +24,9 @@ local tried = {} -- Moves met before, by "level/scene/move": a re -- met again after a death is answered right. PORT_WRONG_EVERY and PORT_MISS_EVERY set the -- two counts, for a run that goes deeper into a game with few lives. local WRONG_EVERY = tonumber(os.getenv("PORT_WRONG_EVERY")) or 5 +-- PORT_ATTRACT=N lets the attract loop run N frames before the coin and START, so its own screens +-- (the rankings, the best percent) are played and traced too. +local ATTRACT = tonumber(os.getenv("PORT_ATTRACT")) or 0 local MISS_EVERY = tonumber(os.getenv("PORT_MISS_EVERY")) or 7 local function wrongMove(view) return (view.move % WRONG_EVERY == 0) and not view.retry @@ -35,6 +39,25 @@ local plan = nil -- Kimmy: the presses planned for the window bei local held = {} -- Kimmy: the switches the plan holds down, by switch. local menuAt = 0 -- Kimmy: the frame a menu was last answered on. KARIS_FRAMES = tonumber(os.getenv("PORT_FRAMES")) or 60000 +-- A map-mode game's pair kinds, by the name of the game's constant for each: what is held with +-- what. A game that does not define a constant (one with no diagonals) simply has no such pair. +KARIS_PAIR_KINDS = { { "ACTUP", 5, 1 }, { "ACTDOWN", 5, 2 }, { "ACTLEFT", 5, 3 }, { "ACTRIGHT", 5, 4 }, { "UPLEFT", 1, 3 }, { "UPRIGHT", 1, 4 }, { "DOWNLEFT", 2, 3 }, { "DOWNRIGHT", 2, 4 } } + + +-- The pair kinds a game defines, keyed by its own numbers; constants maps a name to the number. +function karisPairs(constants) + local found = {} + + for _, kind in ipairs(KARIS_PAIR_KINDS) do + local number = constants[kind[1]] + + if number ~= nil then + found[number] = { kind[2], kind[3] } + end + end + + return found +end -- The framework's move numbers as switches; Kimmy's diagonals are two of them. local SWITCH_OF = { [1] = SWITCH_UP, [2] = SWITCH_DOWN, [3] = SWITCH_LEFT, [4] = SWITCH_RIGHT, [5] = SWITCH_BUTTON1, [6] = SWITCH_BUTTON2, [7] = SWITCH_BUTTON3 } @@ -384,6 +407,235 @@ local function followPlan(frame) end +-- PORT_SERVICE=1 walks the service screens from the attract loop before the game: SERVICE, then +-- the options' first row on to the graphics screen, its rows each changed once (RIGHT) and +-- "Default", round to its first row and on to the performance screen, the same there, on to the +-- options, the same, and EXIT; a press every WALK_EVERY frames, clear of the screens' quarter +-- second between moves. While a screen shows, the trace carries its row and the dips. +local walkService = (os.getenv("PORT_SERVICE") == "1") +local WALK_EVERY = 10 +local WALK = { SWITCH_SERVICE } +local walkAt = nil +local walkEvents = {} + + +-- A screen's rows walked from its first: each one's value changed, the second from last +-- ("Default") used, and, unless it is the last screen, round to the first row and on. +local function walkScreen(rows, leave) + WALK[#WALK + 1] = SWITCH_DOWN + for _ = 2, rows - 2 do + WALK[#WALK + 1] = SWITCH_RIGHT + WALK[#WALK + 1] = SWITCH_DOWN + end + WALK[#WALK + 1] = SWITCH_RIGHT -- Default + WALK[#WALK + 1] = SWITCH_DOWN + if leave then + WALK[#WALK + 1] = SWITCH_RIGHT -- EXIT + else + WALK[#WALK + 1] = SWITCH_DOWN -- Round to the first row ... + WALK[#WALK + 1] = SWITCH_RIGHT -- ... and on to the next screen. + end +end + + +WALK[#WALK + 1] = SWITCH_RIGHT -- The options' first row, on to the graphics screen. +walkScreen(15, false) -- The graphics screen, on to the performance screen. +walkScreen(10, false) -- The performance screen, on to the options. +walkScreen(15, true) -- The options, and EXIT. +local SERVICE_DIPS = { "dip_GameType", "dip_Diffshow", "dip_PlayStyle", "dip_StartLevel", "dip_StartScene", "dip_Rewind", "dip_CoinsPerCredit", "dip_LivesPerCredit", "dip_LimitContinue", "dip_ShowAction", "dip_Hints", "dip_MashtoRun", + "dip_Extravid", "ShowTitle", "ShowLCD", "ShowLevel", "ShowTop", "ScorePos", "LivPos", "CoinPos", "fontType", "fontSize", "mycolor1", "mycolor2", + "dip_Model", "dip_Res", "dip_Display", "dip_Scanlines", "dip_ScType", "dip_LivType", "dip_FontQ" } + + +-- The options screen's row and dips, or the save or exit screen's row and choices, from a reader of +-- the loop's variables, while it shows. +function karisServiceState(screen, read) + local parts = {} + + if (screen == 108) or (screen == 112) then + return "row " .. tostring(read("optSel")) .. " Loadsave " .. tostring(read("dip_Loadsave")) .. " Slot " .. tostring(read("dip_Slot")) .. " InPlayExit " .. tostring(read("bInPlayExit")) + end + if (screen ~= 104) and (screen ~= 113) and (screen ~= 114) then + return nil + end + parts[1] = "rows " .. tostring(read("optSel")) .. " " .. tostring(read("optSelG")) .. " " .. tostring(read("optSelP")) + for _, name in ipairs(SERVICE_DIPS) do + parts[#parts + 1] = name:sub(5) .. " " .. tostring(read(name)) + end + + return table.concat(parts, " ") +end + + +-- Switches tapped, as a walk's events: each a press, then its release. +local function tapsOf(switches) + local events = {} + + for _, switch in ipairs(switches) do + events[#events + 1] = { switch, true } + events[#events + 1] = { switch, false } + end + + return events +end + + +-- PORT_TITLETAPS=1 taps the switches that do something beyond a move while the title plays, +-- before any service walk: the original pauses through the title, so none should count; the +-- trace carries the credits. +local titleTaps = (os.getenv("PORT_TITLETAPS") == "1") + +if titleTaps then + walkEvents = tapsOf({ SWITCH_COIN1, SWITCH_START1, SWITCH_SERVICE, SWITCH_TEST, SWITCH_CONSOLE, SWITCH_SKILL1 }) +end +if walkService then + table.move(tapsOf(WALK), 1, #WALK * 2, #walkEvents + 1, walkEvents) +end + + +-- PORT_PLAYWALK=1 walks the in-play switches, each part once the game has answered so many moves +-- and out of a move's window: a quick save to slot 1 (SKILL1); TEST to the save screen, saving +-- to slot 2 and back to the game; CONSOLE to the exit screen and BACK; START held with SKILL1, +-- loading slot 1; COIN held with START, to the exit screen and BACK. +local PLAY_WALK = (os.getenv("PORT_PLAYWALK") == "1") and { + { after = 2, events = tapsOf({ SWITCH_SKILL1 }) }, + { after = 3, events = tapsOf({ SWITCH_TEST, SWITCH_RIGHT, SWITCH_DOWN, SWITCH_RIGHT, SWITCH_DOWN, SWITCH_RIGHT }) }, + { after = 5, events = tapsOf({ SWITCH_CONSOLE, SWITCH_DOWN, SWITCH_BUTTON1 }) }, + { after = 6, events = { { SWITCH_START1, true }, { SWITCH_SKILL1, true }, { SWITCH_SKILL1, false }, { SWITCH_START1, false } } }, + { after = 8, events = { { SWITCH_COIN1, true }, { SWITCH_START1, true }, { SWITCH_START1, false }, { SWITCH_COIN1, false }, { SWITCH_DOWN, true }, { SWITCH_DOWN, false }, { SWITCH_BUTTON1, true }, { SWITCH_BUTTON1, false } } } +} or {} +local playPart = 1 + +-- The second of PORT_TWICE's runs first loads slot 2 (START held with SKILL2), which the first +-- run saved from the save screen: the slots' part of the Persistence row. +if (#PLAY_WALK > 0) and (os.getenv("PORT_PASS") == "2") then + table.insert(PLAY_WALK, 1, { after = 1, events = { { SWITCH_START1, true }, { SWITCH_SKILL2, true }, { SWITCH_SKILL2, false }, { SWITCH_START1, false } } }) +end + + +-- The next part of the play walk, when the game has answered enough moves and no window is near. +local function karisPlayWalk(view, moves) + local part = PLAY_WALK[playPart] + + if (#walkEvents > 0) or (part == nil) or (view.screen ~= 103) or (moves < part.after) then + return + end + if view.window and (view.frame >= view.window.from - 30) and (view.frame <= view.window.to + 5) then + return + end + playPart = playPart + 1 + walkEvents = { table.unpack(part.events) } + walkAt = nil +end + + +-- One event of the walk each WALK_EVERY frames. True while walking. +local function walk(view) + if #walkEvents == 0 then + return false + end + walkAt = walkAt or view.frames + if view.frames - walkAt < WALK_EVERY then + return true + end + walkAt = view.frames + local event = table.remove(walkEvents, 1) + + karisPress(event[1], event[2]) + + return true +end + + +-- PORT_HUD=1 adds what the HUD shows to each trace line, a letter a flag the loop sets, so an +-- original and its port are compared on what a player sees as well as on the game's state. +local HUD_FLAGS = { + { "S", "bShowScore" }, { "L", "bShowLives" }, { "V", "bShowLvl" }, { "T", "bShowTop" }, + { "C", "bShowCredits" }, { "A", "bShowAction" }, { "K", "bShowSkip" }, { "G", "bShowGet" }, + { "H", "bShowChoices" }, { "D", "bShowLCD" }, { "P", "bPause" }, { "1", "bShowDisk1" }, + { "2", "bShowDisk2" }, { "3", "bShowDisk3" }, { "4", "bShowDiskA" } +} +local traceHud = (os.getenv("PORT_HUD") == "1") + + +-- The HUD flags as letters, from a reader of the loop's variables (the original's globals, or +-- the port's q). +function karisHudFlags(read) + local letters = {} + + if not traceHud then + return nil + end + for _, flag in ipairs(HUD_FLAGS) do + if read(flag[2]) then + letters[#letters + 1] = flag[1] + end + end + + return table.concat(letters) +end + + +-- PORT_SOUND=1 traces each sound played or stopped, by its file's name, and the disc's audio +-- turned off or on, with the disc frame: the Sound row of FORGE.md 14.18. The engine's own calls +-- are wrapped before the game loads, so the original's sounds and the port's (which Forge plays +-- through the same calls) are caught the same way. +if os.getenv("PORT_SOUND") == "1" then + local names = {} + local realSoundLoad = soundLoad + local realSoundPlay = soundPlay + + soundLoad = function(file, ...) + local handle = realSoundLoad(file, ...) + + if handle ~= nil then + names[handle] = tostring(file):match("([^/\\]+)$") + end + + return handle + end + soundPlay = function(handle, ...) + debugPrint(string.format("TRACE sound %s disc %d", tostring(names[handle]), discGetFrame())) + + return realSoundPlay(handle, ...) + end + -- A sound stopped, and the disc's own audio turned off or on, change what is heard too. + local realSoundStop = soundStop + local realDiscAudio = discAudio + + soundStop = function(handle, ...) + debugPrint(string.format("TRACE soundStop %s disc %d", tostring(names[handle]), discGetFrame())) + + return realSoundStop(handle, ...) + end + discAudio = function(channel, enabled, ...) + debugPrint(string.format("TRACE discAudio %s %s disc %d", tostring(channel), tostring(enabled), discGetFrame())) + + return realDiscAudio(channel, enabled, ...) + end +end + + +-- PORT_SHOTS=N takes up to N screenshots, numbered in order: one at each change the trace +-- prints, and one a frame into each move's window (the drive answers within a few). The traces +-- of an original and its port agree, so the same number is the same moment in both, and the +-- pictures can be diffed. +local SHOT_INTO_WINDOW = 1 +-- A second run of PORT_TWICE names its pictures apart from the first's. +local SHOT_PREFIX = ((os.getenv("PORT_PASS") or "1") == "1") and "shot-" or ("shot-run" .. os.getenv("PORT_PASS") .. "-") +local shotsLeft = tonumber(os.getenv("PORT_SHOTS") or "0") or 0 +local shotCount = 0 + + +function karisShot(why) + if shotsLeft > 0 then + shotsLeft = shotsLeft - 1 + shotCount = shotCount + 1 + singeScreenshot(string.format("%s%04d-%s", SHOT_PREFIX, shotCount, why)) + end +end + + function karisTraceSeek(frame) seeks = seeks + 1 debugPrint(string.format("TRACE seek %d", frame)) @@ -399,19 +651,45 @@ function karisDrive(view) end local line = string.format("TRACE screen %s step %s level %s scene %s move %s score %s lives %s", tostring(view.screen), tostring(view.step), tostring(view.level), tostring(view.scene), tostring(view.move), tostring(view.score), tostring(view.lives)) + if view.hud then + line = line .. " hud " .. view.hud + end + if view.service then + line = line .. " " .. view.service + end + if titleTaps then + line = line .. " credits " .. tostring(view.credits) + end + if line ~= lastLine then lastLine = line debugPrint(line .. " disc " .. tostring(view.frame)) + karisShot("state") + end + -- A few frames into a move's window, where its prompt shows. + if view.window and (view.frame == view.window.from + SHOT_INTO_WINDOW) then + karisShot("window") + end + -- The options screen's walk, on the attract loop before anything else, and the play walk's + -- parts. + local moves = 0 + + for _ in pairs(answered) do + moves = moves + 1 + end + karisPlayWalk(view, moves) + if ((playPart > 1) or (view.screen == 0) or (view.screen == 104) or (view.screen == 113) or (view.screen == 114)) and walk(view) then + return end -- A coin on the attract loop when the game is not free play, then START once it has begun -- playing. - if (view.screen == 0) and not started and (view.frame >= 150) and (not view.freeplay) and (view.credits == 0) and not coined then + if (view.screen == 0) and not started and (view.frame >= 150) and (view.frames >= ATTRACT) and (not view.freeplay) and (view.credits == 0) and not coined then coined = true pressed = SWITCH_COIN1 karisPress(pressed, true) return end - if (view.screen == 0) and not started and (view.frame >= 160) and (view.freeplay or (view.credits > 0)) then + if (view.screen == 0) and not started and (view.frame >= 160) and (view.frames >= ATTRACT) and (view.freeplay or (view.credits > 0)) then started = true pressed = SWITCH_START1 karisPress(pressed, true) diff --git a/testScripts/ports/karisOriginal.singe b/testScripts/ports/karisOriginal.singe index 2a4d92958..f7cf8373f 100644 --- a/testScripts/ports/karisOriginal.singe +++ b/testScripts/ports/karisOriginal.singe @@ -68,7 +68,7 @@ local function view() local pairs = nil if rdg and ACTUP then - pairs = { [ACTUP] = { 5, 1 }, [ACTDOWN] = { 5, 2 }, [ACTLEFT] = { 5, 3 }, [ACTRIGHT] = { 5, 4 }, [UPLEFT] = { 1, 3 }, [UPRIGHT] = { 1, 4 }, [DOWNLEFT] = { 2, 3 }, [DOWNRIGHT] = { 2, 4 } } + pairs = karisPairs(_G) end -- A choice: the framework's at branch10, the later map-mode copies' at branch02. if ((lvlState == branch10) or (rdg and (lvlState == branch02))) and choice then @@ -86,7 +86,8 @@ local function view() return { screen = currentLevel, step = lvlState, level = iLevel or iCurPos or iStageIndex, scene = iScene or iSegPointer, move = currentMove, frame = discGetFrame(), frames = frames, score = iScore, lives = iLives, window = window, choices = chosen, order = optorder, choice = iChoice, kimmy = kimmy, rdg = rdg, timegal = timegal, multi = kimmy and multi and multi[currentMove] or nil, path = kimmy and path and path[currentMove] or nil, timed = kimmy and timed or nil, - freeplay = (dip_CoinsPerCredit == DOPT_FREEPLAY), credits = iCredits, zone = zone, mouse = mouse, pairs = pairs, mashKind = rdg and MASH or nil } + freeplay = (dip_CoinsPerCredit == DOPT_FREEPLAY), credits = iCredits, zone = zone, mouse = mouse, pairs = pairs, mashKind = rdg and MASH or nil, + hud = karisHudFlags(function(name) return _G[name] end), service = karisServiceState(currentLevel, function(name) return _G[name] end) } end diff --git a/testScripts/ports/karisPort.singe b/testScripts/ports/karisPort.singe index f7fd22d53..7201c225e 100644 --- a/testScripts/ports/karisPort.singe +++ b/testScripts/ports/karisPort.singe @@ -36,11 +36,15 @@ end local function view() local q = authorEntity("game").q + -- The style the qte behaviour plays, as the flags the view reports. + local held = (q.qteStyle == "held") + local checkpoints = (q.qteStyle == "checkpoints") + local segmented = (q.qteStyle == "segments") or checkpoints or (q.qteStyle == "trophies") local m = q.move[q.currentMove] local window = nil local chosen = nil - if q.timegal then + if checkpoints then if (q.currentLevel == 103) and m and (m[2] ~= nil) then window = { from = m[2], to = m[3], kind = m[7], death = 0, alt = 0, alt2 = 0 } end @@ -51,16 +55,16 @@ local function view() local mouse = q.ratiox and { q.ratiox, q.ratioy, q.ratioxOffset, q.ratioyOffset } or nil local pairs = nil - if q.rdg and q.ACTUP then - pairs = { [q.ACTUP] = { 5, 1 }, [q.ACTDOWN] = { 5, 2 }, [q.ACTLEFT] = { 5, 3 }, [q.ACTRIGHT] = { 5, 4 }, [q.UPLEFT] = { 1, 3 }, [q.UPRIGHT] = { 1, 4 }, [q.DOWNLEFT] = { 2, 3 }, [q.DOWNRIGHT] = { 2, 4 } } + if segmented and q.ACTUP then + pairs = karisPairs(q) end - if ((q.lvlState == 19) or (q.rdg and (q.lvlState == 11))) and q.choice then + if ((q.lvlState == 19) or (segmented and (q.lvlState == 11))) and q.choice then chosen = {} for index, c in ipairs(q.choice) do chosen[index] = { right = c[2] } end end - if q.timegal and (q.lvlState == 12) and q.opt then + if checkpoints and (q.lvlState == 12) and q.opt then chosen = {} for index, c in ipairs(q.opt) do chosen[index] = { right = c[2] } @@ -71,17 +75,18 @@ local function view() -- numbers it, and reports it as the original does. local screen = q.currentLevel - if q.kimmy and (screen == q.levelNG) then + if held and (screen == q.levelNG) then screen = nil end -- The map-mode lineage never numbered its finish screen either. - if q.rdg and (screen == QTE_LEVEL_FINISH) then + if segmented and (screen == QTE_LEVEL_FINISH) then screen = nil end return { screen = screen, step = q.lvlState, level = q.iLevel or q.iCurPos or q.iStageIndex, scene = q.iScene or q.iSegPointer, move = q.currentMove, frame = discGetFrame(), frames = frames, score = q.iScore, lives = q.iLives, window = window, choices = chosen, order = q.optorder, choice = q.iChoice, - kimmy = q.kimmy, rdg = q.rdg, timegal = q.timegal, multi = q.kimmy and q.multi[q.currentMove] or nil, path = q.kimmy and q.path[q.currentMove] or nil, timed = q.kimmy and q.timed or nil, - freeplay = (q.dip_CoinsPerCredit == q.DOPT_FREEPLAY), credits = q.iCredits, zone = zone, mouse = mouse, pairs = pairs, mashKind = q.rdg and q.MASH or nil } + kimmy = held, rdg = segmented, timegal = checkpoints, multi = held and q.multi[q.currentMove] or nil, path = held and q.path[q.currentMove] or nil, timed = held and q.timed or nil, + freeplay = (q.dip_CoinsPerCredit == q.DOPT_FREEPLAY), credits = q.iCredits, zone = zone, mouse = mouse, pairs = pairs, mashKind = segmented and q.MASH or nil, + hud = karisHudFlags(function(name) return q[name] end), service = karisServiceState(screen, function(name) return q[name] end) } end diff --git a/testScripts/ports/ledger.md b/testScripts/ports/ledger.md new file mode 100644 index 000000000..61fe662e6 --- /dev/null +++ b/testScripts/ports/ledger.md @@ -0,0 +1,242 @@ +# The ledger (FORGE.md section 17) + +Generated by `util/forgeLedger.py` on 2026-09-28 from the converter's report for each +description it made; do not edit by hand. **forge** is a field of Forge's own model, +**framework** something Forge plays under the framework's names or numbers, **lua** +something the game's own Lua still does. Rows are sorted by status, then by how many +titles use them: the top of the table is what most blocks titles from being Forge's own. +A hook from the add-ons file most titles ship unchanged (19 of them) is the framework's +stock one, and counts as framework. The globals are every global the game's script left +that nothing names: most are the framework's own state and picture numbers, not data. + +76 titles reported; 0 failed to report; 5 descriptions made by another tool (not covered). + +## Capabilities + +| Status | Capability | Titles | Some of them | Details | +|---|---|---|---|---| +| lua | script | 49 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, conan_singe_2, ... | AlteredCarbon.singe; Arcade_Xperience_Vol1.singe; Arcade_Xperience_Vol2.singe; Arcade_Xperience_Vol3.singe; Asterix.singe; Astroboy.singe; BD13.singe; Chantze.singe; ... | +| lua | vocabulary | 27 | crimepatrol, drugwars, drugwars, crimepatrol, timetraveler, lastbountyhunter, ... | Vocabulary.singe | +| lua | levelFunctions | 11 | conan_singe_2, daitarn_singe_2, dragon_singe_2, dlair, Puss_in_Boots, jack_singe_2, ... | Puss_in_Boots.singe; Sdq.singe; conan_singe_2.singe; daitarn_singe_2.singe; dlair.singe; dragon_singe_2.singe; freedomfighter.singe; hq-timegal.singe; ... | +| lua | hook.startConf | 6 | DL2e, DLe, SAe, BD13, Esh_Aurunmilla, hayate_1080 | Script/addons.singe | +| lua | hook.swapLevel | 6 | DL2e, DLe, SAe, BD13, Esh_Aurunmilla, hayate_1080 | Script/addons.singe | +| lua | hook.doLevelSelect | 4 | Chantze, Triad, f13, SuckerPunch | Script/addons.singe | +| lua | hook.moveCursor | 4 | Chantze, Triad, f13, SuckerPunch | Script/addons.singe | +| lua | hook.specialScore | 3 | DL2e, DLe, BD13 | Script/addons.singe | +| lua | hook.swapDeath | 3 | SAe, BD13, hayate_1080 | Script/addons.singe | +| lua | hook.AllowLvlEnd | 2 | Chantze, Triad | Script/addons.singe | +| lua | hook.AllowLvlQuartz | 2 | Chantze, Triad | Script/addons.singe | +| lua | hook.LvlMapComplete | 2 | f13, SuckerPunch | Script/addons.singe | +| lua | hook.ShowComplete | 2 | Chantze, Triad | Script/addons.singe | +| lua | hook.TestDips | 2 | Chantze, Triad | Script/addons.singe | +| lua | hook.TestState | 2 | Chantze, Triad | Script/addons.singe | +| lua | hook.loadSavePlus | 2 | DL2e, BD13 | Script/addons.singe | +| lua | hook.mapConf | 2 | Chantze, Triad | Script/addons.singe | +| lua | hook.writeSavePlus | 2 | DL2e, BD13 | Script/addons.singe | +| framework | setting.* | 75 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, conan_singe_2, ... | | +| framework | global.* | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| framework | judge.press.direction | 36 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | DOWN; LEFT; RIGHT; UP | +| framework | judge.press.button | 35 | AlteredCarbon, Asterix, Cliff, DL2e, DLe, Fire_and_Ice, ... | BUTTON1; BUTTON2; BUTTON3 | +| framework | judge.skip | 30 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, Fire_and_Ice, ... | SKIP | +| framework | judge.mash.one | 25 | AlteredCarbon, Asterix, Chantze, Triad, Fire_and_Ice, Fire_and_Ice_v2, ... | MASH; MASHMAX; MASHMIN | +| framework | hook.LevelMapComplete | 21 | AlteredCarbon, Asterix, Cliff, Fire_and_Ice, Fire_and_Ice_v2, LINEA, ... | empty; stock add-ons | +| framework | hook.doLevelSelect | 21 | AlteredCarbon, Asterix, Cliff, Fire_and_Ice, Fire_and_Ice_v2, LINEA, ... | empty; stock add-ons | +| framework | hook.moveCursor | 21 | AlteredCarbon, Asterix, Cliff, Fire_and_Ice, Fire_and_Ice_v2, LINEA, ... | empty; stock add-ons | +| framework | hook.swapScene | 21 | AlteredCarbon, Asterix, Cliff, Fire_and_Ice, Fire_and_Ice_v2, f13, ... | empty; stock add-ons | +| framework | judge.chord.buttonDirection | 20 | AlteredCarbon, Asterix, Chantze, Triad, Fire_and_Ice, Fire_and_Ice_v2, ... | ACTDOWN; ACTLEFT; ACTRIGHT; ACTUP | +| framework | option.style | 18 | conan_singe_2, daitarn_singe_2, dragon_singe_2, dlair, Puss_in_Boots, jack_singe_2, ... | checkpoints; held; segments; trophies | +| framework | judge.hold.direction | 16 | AlteredCarbon, Fire_and_Ice_v2, f13, LINEA, mononoke, oeil-pour-oeil, ... | HOLDDOWN; HOLDLEFT; HOLDRIGHT; HOLDUP | +| framework | judge.mash.run | 15 | AlteredCarbon, Fire_and_Ice_v2, f13, mononoke, SuckerPunch, Titan_AE, ... | RUN; RUNMAX; RUNMIN | +| framework | judge.press.diagonal | 14 | Fire_and_Ice_v2, LINEA, mononoke, oeil-pour-oeil, SuckerPunch, the-elder-scrolls, ... | DOWNLEFT; DOWNRIGHT; UPLEFT; UPRIGHT | +| framework | judge.hold.button | 12 | AlteredCarbon, LINEA, oeil-pour-oeil, the-elder-scrolls, Tron, Arcade_Xperience_Vol2, ... | HOLDBUT | +| framework | judge.jump | 9 | DL2e, DLe, Hayate, SAe, BD13, Esh_Aurunmilla, ... | TOLEVEL; TOSCENE; WAY; WAYOUT | +| framework | judge.path | 9 | DL2e, DLe, Hayate, SAe, BD13, StarshipTroopers, ... | PATH | +| framework | persist.dieAndRetryRecords | 7 | BD13, FBC, FumaConspiracy, Sonic_the_Hedgehog_1996, hayate_1080, mazinga, ... | | +| framework | judge.circle | 5 | Arcade_Xperience_Vol1, Arcade_Xperience_Vol2, Arcade_Xperience_Vol3, Danmachi, mazinga | LOOP; LOOPLEFT; LOOPRIGHT | +| framework | judge.double | 5 | AlteredCarbon, Fire_and_Ice_v2, SuckerPunch, Tron, mazinga | DOUBLE | +| framework | judge.letGo | 5 | Fire_and_Ice_v2, mononoke, Tron, Danmachi, Esh_Aurunmilla | LETGO | +| framework | judge.mash.two | 5 | LINEA, Arcade_Xperience_Vol1, Arcade_Xperience_Vol2, Arcade_Xperience_Vol3, Astroboy | MASH2MAX; MASH2MIN | +| framework | judge.count | 3 | LINEA, Danmachi, mazinga | MULTI | +| framework | judge.timed | 3 | DLe, SAe, badlands_lite | TIMED | +| framework | hook.swapDeath | 2 | Esh_Aurunmilla, Sintel | empty | +| framework | judge.choose | 2 | Timegal, FumaConspiracy | CHOOSE | +| framework | judge.unnamed | 2 | LINEA, Arcade_Xperience_Vol2 | kind 156 (UL); kind 157 (UR); kind 158 (DL); kind 159 (DR); kind 160 (LU); kind 162 (LD); kind 167 (LDB); kind 181 (DRU); ... | +| framework | hook.loadSavePlus | 1 | Sintel | empty | +| framework | hook.specialScore | 1 | Sintel | empty | +| framework | hook.startConf | 1 | Sintel | empty | +| framework | hook.swapLevel | 1 | Sintel | empty | +| framework | hook.writeSavePlus | 1 | Sintel | empty | +| framework | judge.anything | 1 | BD13 | ANYTHING | +| framework | judge.hold | 1 | mazinga | HOLD | +| framework | judge.sequence | 1 | LINEA | DU; LR; RL; UD | +| framework | judge.yesNo | 1 | SAe | YESNO | +| framework | order.trophies | 1 | Sdq | | +| framework | scoring.percents | 1 | Sdq | | +| framework | tweak.introHold | 1 | freedomfighter | | +| framework | tweak.rightSound | 1 | freedomfighter | | +| forge | persist.boards | 76 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, conan_singe_2, ... | | +| forge | deaths.clips | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | deaths.randomCount | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | lifeBar.missCost | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | lifeBar.regainAfter | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | lifeBar.size | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | order.levels | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | order.play | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | order.tiers | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | persist.saves | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.attract | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.clear | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.continue | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.enterName | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.extraDeath | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.gameIntro | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.gameOver | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.gameOverAlt | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.getReady | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.newHighScore | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.rankings | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.title | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | secret.allowed | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | still.controls | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | still.options | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | still.rankings | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | still.save | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | still.special | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | still.trophy | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | still.victory | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | video.fps | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | video.relativeFrames | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | still.hints | 37 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | scoring.scene | 36 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | still.secret | 36 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | order.mapStart | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | scoring.deathPenalty | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | scoring.extraLifeEvery | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | scoring.game | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | scoring.level | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | scoring.move | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | scoring.noDeathBonus | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | scoring.perDifficulty | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | scoring.secret | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | windowDelay.extreme | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | windowDelay.hard | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | windowDelay.normal | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | family.qte.windows | 31 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | 3.31c; 3.32b; Mazescater 1.00 | +| forge | still.easy | 31 | AlteredCarbon, Chantze, Triad, Cliff, DL2e, DLe, ... | | +| forge | still.normal | 31 | AlteredCarbon, Chantze, Triad, Cliff, DL2e, DLe, ... | | +| forge | still.extreme | 30 | AlteredCarbon, Chantze, Triad, Cliff, DL2e, DLe, ... | | +| forge | still.hard | 30 | AlteredCarbon, Chantze, Triad, Cliff, DL2e, DLe, ... | | +| forge | family.alg | 25 | crimepatrol, drugwars, drugwars, crimepatrol, lastbountyhunter, lastbountyhunter, ... | | +| forge | option.gameOverAfterContinue | 11 | Arcade_Xperience_Vol1, Arcade_Xperience_Vol2, Arcade_Xperience_Vol3, Astroboy, Danmachi, Esh_Aurunmilla, ... | | +| forge | option.secretInputs | 11 | Arcade_Xperience_Vol1, Arcade_Xperience_Vol2, Arcade_Xperience_Vol3, Astroboy, Danmachi, Esh_Aurunmilla, ... | | +| forge | family.qte.segments | 9 | conan_singe_2, daitarn_singe_2, dragon_singe_2, dlair, Puss_in_Boots, jack_singe_2, ... | 1.1; 2.2 | +| forge | family.qte.held | 7 | BD13, FBC, FumaConspiracy, Sonic_the_Hedgehog_1996, hayate_1080, mazinga, ... | 4.0 | +| forge | scene.swaps | 6 | Chantze, Triad, DL2e, SuckerPunch, Tron, BD13 | read from swapScene | +| forge | screen.quit | 6 | BD13, FBC, FumaConspiracy, hayate_1080, mazinga, Sintel | | +| forge | still.newGame | 6 | BD13, FBC, FumaConspiracy, hayate_1080, mazinga, Sintel | | +| forge | still.quit | 6 | BD13, FBC, FumaConspiracy, hayate_1080, mazinga, Sintel | | +| forge | family.qte.checkpoints | 1 | hq-timegal | | +| forge | family.qte.trophies | 1 | Sdq | 2.21 | +| forge | family.timetraveler | 1 | timetraveler | | +| forge | family.videoland | 1 | Rollercoaster | | +| forge | option.alternateMashRate | 1 | LINEA | | +| forge | option.doubleKind | 1 | LINEA | | +| forge | option.hintSeconds | 1 | LINEA | | +| forge | option.mashAnySwitch | 1 | LINEA | | +| forge | still.difficult | 1 | Titan_AE | | +| forge | still.rankingsAlt | 1 | BD13 | | + +## Settings under the framework's names (69) + +By how many titles use each: dip_LivesPerCredit 75, dip_Difficulty 74, dip_CoinsPerCredit 58, dip_LimitContinue 50, dip_ShowAction 49, dip_GameType 48, dip_StartLevel 48, dip_Rewind 47, dip_Diffshow 39, dip_Extravid 39, dip_Hints 39, dip_Res 39, dip_StartScene 39, CoinPos 38, LivPos 38, ScorePos 38, ShowLCD 38, ShowLevel 38, ShowTitle 38, ShowTop 38, dip_Display 38, dip_FontQ 38, dip_MashtoRun 38, dip_Model 38, dip_PlayStyle 38, dip_ScType 38, fontSize 38, fontType 38, mycolor1 38, mycolor2 38, dip_HoldtoLoop 36, dip_LivType 36, dip_Scanlines 34, dip_Undertaker 25, dip_Crosshair 24, dip_ReloadTrigger 19, dip_Showdown 17, dip_Lang 12, dip_AllowContinue 11, dip_Practice 11, dip_Debug 10, dip_HUD 10, dip_Kidmode 10, dip_Movie 10, dip_StartAtPart 10, dip_StartSegment 9, dip_CoinsPerContinue 8, dip_MashRes 7, dip_Next 7, dip_OVLS 7, dip_Tilt 7, mycolor3 7, dip_MaxBullets 5, dip_PoolIndex 5, dip_MustBeatLevel 2, dip_Bilinear 1, dip_Chroma 1, dip_IntroType 1, dip_PlayEveryLevel 1, dip_RevCubeMax 1, dip_ShowHellgate 1, dip_ShowWizard 1, dip_SortStyle 1, dip_Special 1, dip_showAction 1, t1 1, t2 1, t3 1, t4 1. + +## Globals nothing names yet (455) + +By how many titles use each: ARROWSELL 38, ARROWSELR 38, BARFULL 38, BARNONE 38, BONUS 38, BUTTONS 38, CREDIT 38, D1 38, D2 38, D3 38, DA 38, DEATH 38, FREEPLAY 38, ForceRewind 38, GETREADY 38, Group1 38, Group2 38, Group3 38, Group4 38, Hit 38, INSERTCOIN 38, LEVEL 38, LIVES 38, LvlMap 38, MINUS 38, MOVE 38, MovieFPS 38, NOMOVES 38, OKMAP 38, PERFECT 38, PW0 38, PW1 38, PW2 38, PW3 38, PW4 38, PW5 38, PW6 38, PW7 38, PWFULL 38, SCAN 38, SCENES 38, SCORE 38, SECRET 38, SELMAP 38, SKIPINTRO 38, SPECIALS 38, STICK 38, TB 38, TG 38, TIME 38, TN 38, TOPSCORE 38, TOTAL 38, TP 38, TS 38, Tlimit 38, altCfg 38, altState 38, altState2 38, b1PEnd 38, b1PStart 38, b2PEnd 38, b2PStart 38, bAct 38, bAllowSave 38, bCalc 38, bCheckForCredits 38, bCheckMove 38, bExtendedPlay 38, bFlip 38, bGOAlt 38, bGR 38, bIgnoreJoy 38, bInPlayExit 38, bLCDLineWait 38, bLevelComplete 38, bOneDiff 38, bPath 38, bPause 38, bPlayPrompt 38, bRes 38, bResetContinue 38, bSave 38, bShowAction 38, bShowCredits 38, bShowDisk 38, bShowGet 38, bShowLCD 38, bShowLives 38, bShowLvl 38, bShowScore 38, bShowSkip 38, bShowTop 38, bShuffleOrder 38, bSkipIntroClip 38, bSwap 38, bTestHold 38, bTestMash 38, bTestRunL 38, bTestRunR 38, bTime 38, curPath 38, currentFrame 38, currentLevel 38, currentMove 38, diffState 38, dip_Loadsave 38, dip_Slot 38, finalstage 38, flow_GameInit 38, flow_GameRunning 38, flow_VLDPStart 38, fontSizeNew 38, gameflow 38, i1PScore 38, i2P 38, i2PScore 38, iBonus 38, iChoice 38, iCoins 38, iContinues 38, iCredits 38, iDeathCount 38, iExtraLife 38, iLCDline 38, iLCDpointer 38, iLCDwait 38, iLCDx 38, iLastFiller 38, iLenHold 38, iLevel 38, iLevelEnd 38, iLevelStart 38, iLifeBar 38, iLiveSave 38, iLives 38, iMash 38, iMovie 38, iPath 38, iPathA 38, iPathAend 38, iPathAjmp 38, iPathB 38, iPauseFrame 38, iPenal 38, iRightMv 38, iRoll 38, iScDeath 38, iScPlayed 38, iScene 38, iScore 38, iScoreSave 38, iScoreTemp 38, iStageIndex 38, iTempLevel 38, iTop 38, iTotDeath 38, iWrongMv 38, lastHold 38, lenCounter 38, lfs 38, lvlState 38, mashCounter 38, numChoice 38, numTrophy 38, p1BUTTON1 38, p1BUTTON2 38, p1BUTTON3 38, p1COIN1 38, p1COIN2 38, p1CONSOLE 38, p1DOWN 38, p1LEFT 38, p1PAUSE 38, p1RIGHT 38, p1SERVICE 38, p1START1 38, p1START2 38, p1UP 38, posGauge 38, sLCD 38, scene 38, segID 38, sndclear 38, sndcoin 38, sndcredit 38, snddeath 38, sndright 38, sndroll 38, sndvictory 38, sndwrong 38, stage 38, t1 38, t1l 38, t2 38, t2l 38, t3 38, t3l 38, t4 38, t4l 38, thisMove 38, ycoinpos 38, ydskpos 38, ylivpos 38, ylvlpos 38, yscopos 38, offsetDeath 37, offsetMenus 37, offsetMovieEnd 37, AllowMap 35, AllowRandom 35, IngameDiffchoice 35, LvlTrophy3 35, LvlTrophy4 35, miscB 34, miscG 34, miscR 34, offsetMap 34, offsetMapend 34, AllowTier 33, HOLD 31, level2P 31, level2PEnd 31, bScan 30, bShowCubes 20, iMulti 19, LivesPrev 18, ScorePrev 18, LangOpt 14, BASEOVERLAY 12, DOUBLE 12, OVERLAY_FULL 12, OVERLAY_HALF 12, OVERLAY_OVERSIZE 12, iLoopPrev 12, iLoopStep 12, PlayerNum 11, SBCOH 11, SBCOW 11, dip_MinimalOverlay 11, OVERLAY_DEFAULT 9, dip_Lang 8, AllowDie 7, AllowKid 7, AllowLifeBar 7, AllowNormal 7, AllowPercent 7, AllowSurvivor 7, B1 7, B2 7, B3 7, D 7, DL 7, DR 7, HDOWN 7, HLEFT 7, HRIGHT 7, HUP 7, KIMMY 7, L 7, MASH3 7, NB1 7, NB2 7, NB3 7, NDL 7, NDOWN 7, NDR 7, NEMPTY 7, NHDOWN 7, NHLEFT 7, NHOLD 7, NHRIGHT 7, NHUP 7, NLEFT 7, NM 7, NRIGHT 7, NSDOWN 7, NSLEFT 7, NSRIGHT 7, NSUP 7, NUL 7, NUMX 7, NUP 7, NUR 7, PAUSED 7, R 7, SKINDIR 7, TILT 7, U 7, UL 7, UR 7, WAIT 7, WARNING 7, acombo 7, bLvlJump 7, bNoScreen 7, bScnJump 7, bShowFrame 7, bShowNext 7, bShowTilt 7, bShowWarnTilt 7, bTestRunD 7, bTestRunU 7, bUnlockSel 7, dip_LiveType 7, gcombo 7, iCountCombo 7, iTilt 7, iTiltMv 7, iTimer 7, iTrigTilt 7, levelDR 7, mcombo 7, multi 7, n 7, pathLook 7, xCredit 7, xFreePlay 7, xTop 7, MazeGame 6, bAddons 3, devMode 2, dip_Bilinear 2, Allow2Player 1, ArcadeMode 1, BUTTONMASHB2 1, BUTTONMASHB3 1, BUTTONMASHDOWN 1, BUTTONMASHLEFT 1, BUTTONMASHRIGHT 1, BUTTONMASHUP 1, CPHeight 1, CPHeightLo 1, CPwidth 1, CPwidthLo 1, CreditPannel 1, CreditPannelLo 1, LEDBarw 1, LEDBarwLo 1, LEDPannel 1, LPwidth 1, LPwidthLo 1, LivesPannel 1, LivesPannelLo 1, SSDheight 1, SSDheightLo 1, SSDwidth 1, SSDwidthLo 1, ScorePannel 1, ScorePannelLo 1, SpriteMenu 1, a 1, alCfg 1, bBilinear 1, bBranchDeath 1, bLang 1, bShowTitle 1, disable2P 1, disable2PLo 1, disable2Pw 1, disable2PwLo 1, exitmenu 1, exitmenuLo 1, exitmenuh 1, exitmenuhLo 1, exitmenuw 1, exitmenuwLo 1, gap 1, h_OverlayTweak 1, hyplogo 1, hyplogoLo 1, hyplogow 1, hyplogowLo 1, insertCoinb 1, insertCoinbLo 1, insertCoinw 1, insertCoinwLo 1, ledFull 1, ledFullLo 1, ledNone 1, ledNoneLo 1, patch_mazinga 1, press1p 1, press1pLo 1, press1pw 1, press1pwLo 1, sndchange 1, sndenter 1, sndprompt 1, sndselect 1, sndtest 1, sprSSD 1, sprSSDLo 1, w_OverlayTweak 1, waitLook 1, xLEDCredits 1, xLEDCreditsLo 1, xLEDLives 1, xLEDLivesLo 1, xLEDScore 1, xLEDScoreLo 1, xattract 1, xattractLo 1, xattractb 1, xattractbLo 1, xattractw 1, xattractwLo 1, xback 1, xbackLo 1, xbackb 1, xbackbLo 1, xbackw 1, xbackwLo 1, xcontinue 1, xcontinueLo 1, xcontinueb 1, xcontinuebLo 1, xcontinuew 1, xcontinuewLo 1, xnewgame 1, xnewgameLo 1, xnewgameb 1, xnewgamebLo 1, xnewgamew 1, xnewgamewLo 1, xoptions 1, xoptionsLo 1, xoptionsb 1, xoptionsbLo 1, xoptionsw 1, xoptionswLo 1, xpaused 1, xpausedLo 1, xpausedh 1, xpausedhLo 1, xpausedw 1, xpausedwLo 1, xquit 1, xquitLo 1, xquitb 1, xquitbLo 1, xquitw 1, xquitwLo 1, xreturn 1, xreturnLo 1, xreturnb 1, xreturnbLo 1, xreturnw 1, xreturnwLo 1, yLEDCredits 1, yLEDCreditsLo 1, yLEDLives 1, yLEDLivesLo 1, yLEDScore 1, yLEDScoreLo 1. + +## Titles + +Runs without its Lua: `standalone.txt`, last proved 2026-09-24. + +| Title | Family | forge | framework | lua | Runs without its Lua | +|---|---|---|---|---|---| +| Rollercoaster/Rollercoaster | videoland | 2 | 0 | 1 | not on the list | +| hypseus/typing-md2/singe/typing-md2/typing-md2 | alg | 2 | 4 | 1 | not on the list | +| CrimePatrol2DrugWarsHD/Script/drugwars | alg | 2 | 6 | 1 | not on the list | +| CrimePatrolHD/Script/crimepatrol | alg | 2 | 6 | 1 | not on the list | +| CrimePatrol/Script/crimepatrol | alg | 2 | 7 | 1 | not on the list | +| SpacePirates/Script/spacepirates | alg | 2 | 7 | 1 | not on the list | +| SpacePiratesHD/Script/spacepirates | alg | 2 | 7 | 1 | not on the list | +| hypseus/crimepatrol-hd/singe/crimepatrol-hd/crimepatrol-hd | alg | 2 | 7 | 1 | not on the list | +| hypseus/drugwars-hd/singe/drugwars-hd/drugwars-hd | alg | 2 | 7 | 1 | not on the list | +| hypseus/lbh-hd/singe/lbh-hd/lbh-hd | alg | 2 | 7 | 1 | not on the list | +| hypseus/spacepirates-hd/singe/spacepirates-hd/spacepirates-hd | alg | 2 | 7 | 1 | not on the list | +| CrimePatrol2DrugWars/Script/drugwars | alg | 2 | 8 | 1 | not on the list | +| LastBountyHunter/Script/lastbountyhunter | alg | 2 | 8 | 1 | not on the list | +| LastBountyHunterHD/Script/lastbountyhunter | alg | 2 | 8 | 1 | not on the list | +| Platoon/Script/platoon | alg | 2 | 8 | 1 | not on the list | +| hypseus/maddog2-hd/singe/maddog2-hd/maddog2-hd | alg | 2 | 8 | 1 | not on the list | +| MadDogMcCree/Script/maddog | alg | 2 | 9 | 1 | not on the list | +| MadDogMcCree2/Script/maddog2 | alg | 2 | 9 | 1 | not on the list | +| MadDogMcCree2HD/Script/maddog2 | alg | 2 | 9 | 1 | not on the list | +| MadDogMcCreeHD/Script/maddog | alg | 2 | 9 | 1 | not on the list | +| WhoShotJohnnyRock/Script/johnnyrock | alg | 2 | 9 | 1 | not on the list | +| WhoShotJohnnyRockHD/Script/johnnyrock | alg | 2 | 9 | 1 | not on the list | +| hypseus/WSJR_HD/WSJR_HD | alg | 2 | 9 | 1 | not on the list | +| hypseus/johnnyrocknoir/johnnyrocknoir | alg | 2 | 9 | 1 | not on the list | +| hypseus/maddog-hd/singe/maddog-hd/maddog-hd | alg | 2 | 9 | 1 | not on the list | +| hypseus/wsjr-hd/singe/wsjr-hd/wsjr-hd | alg | 2 | 10 | 1 | not on the list | +| HologramTimeTraveler/Script/timetraveler | timetraveler | 2 | 11 | 1 | not on the list | +| NinjaHayateHD/Hayate | qte.windows | 52 | 266 | 1 | yes | +| TimeGal/Timegal | qte.windows | 52 | 268 | 1 | yes | +| CliffHanger/Cliff | qte.windows | 52 | 269 | 1 | yes | +| Asterix/Asterix | qte.windows | 48 | 273 | 1 | yes | +| FireAndIce/Fire_and_Ice | qte.windows | 52 | 273 | 1 | yes | +| TheElderScrolls/the-elder-scrolls | qte.windows | 48 | 274 | 1 | yes | +| TitanAE/Titan_AE | qte.windows | 52 | 278 | 1 | yes | +| hypseus/badlands_lite/singe/badlands_lite/badlands_lite | qte.windows | 54 | 278 | 1 | yes | +| AlteredCarbon/AlteredCarbon | qte.windows | 52 | 281 | 1 | yes | +| OeilPourOeil/oeil-pour-oeil | qte.windows | 48 | 281 | 1 | yes | +| hypseus/Survival/singe/Survival/Survival | qte.windows | 54 | 281 | 1 | yes | +| FireAndIce/Fire_and_Ice_v2 | qte.windows | 52 | 284 | 1 | yes | +| Tron/Tron | qte.windows | 54 | 284 | 1 | yes | +| hypseus/Freddy/singe/Freddy/Freddy | qte.windows | 54 | 286 | 1 | not on the list | +| hypseus/StarshipTroopers/singe/StarshipTroopers/StarshipTroopers | qte.windows | 54 | 286 | 1 | not on the list | +| Mononoke/mononoke | qte.windows | 52 | 287 | 1 | not on the list | +| hypseus/Sugar_Rush/singe/Sugar_Rush/Sugar_Rush | qte.windows | 54 | 287 | 1 | not on the list | +| hypseus/Arcade_Xperience_Vol1/singe/Arcade_Xperience_Vol1/Arcade_Xperience_Vol1 | qte.windows | 54 | 301 | 1 | yes | +| hypseus/Arcade_Xperience_Vol2/singe/Arcade_Xperience_Vol2/Arcade_Xperience_Vol2 | qte.windows | 54 | 302 | 1 | not on the list | +| hypseus/Astroboy/singe/Astroboy/Astroboy | qte.windows | 54 | 302 | 1 | not on the list | +| hypseus/Arcade_Xperience_Vol3/singe/Arcade_Xperience_Vol3/Arcade_Xperience_Vol3 | qte.windows | 54 | 304 | 1 | not on the list | +| hypseus/Danmachi/singe/Danmachi/Danmachi | qte.windows | 54 | 306 | 1 | not on the list | +| Linea/LINEA | qte.windows | 56 | 331 | 1 | yes | +| hypseus/sintel/singe/Sintel/Sintel | qte.held | 38 | 346 | 1 | not on the list | +| hypseus/FBC/singe/FBC/FBC | qte.held | 54 | 351 | 1 | not on the list | +| hypseus/FumaConspiracy/singe/FumaConspiracy/FumaConspiracy | qte.held | 52 | 351 | 1 | not on the list | +| hypseus/Sonic_the_Hedgehog_1996/singe/Sonic_the_Hedgehog_1996/Sonic_the_Hedgehog_1996 | qte.held | 52 | 354 | 1 | yes | +| hypseus/mazinga/singe/mazinga/mazinga | qte.held | 55 | 364 | 1 | yes | +| hypseus/hq-timegal/hq-timegal | qte.checkpoints | 2 | 10 | 2 | not on the list | +| hypseus/jeeg/singe/jeeg/jeeg | qte.segments | 2 | 13 | 2 | not on the list | +| hypseus/starblazers/starblazers | qte.segments | 2 | 13 | 2 | not on the list | +| Conan/Script/conan_singe_2 | qte.segments | 2 | 14 | 2 | not on the list | +| Daitarn3/Script/daitarn_singe_2 | qte.segments | 2 | 14 | 2 | not on the list | +| DragonTrainer/Script/dragon_singe_2 | qte.segments | 2 | 14 | 2 | not on the list | +| DragonsLairTvShow/Script/dlair | qte.segments | 2 | 14 | 2 | not on the list | +| PussInBoots/Script/Puss_in_Boots | qte.segments | 2 | 14 | 2 | not on the list | +| SamuraiJack/Script/jack_singe_2 | qte.segments | 2 | 14 | 2 | not on the list | +| hypseus/freedomfighter/freedomfighter | qte.segments | 2 | 15 | 2 | not on the list | +| SuperDonQuixote/Script/Sdq | qte.trophies | 2 | 24 | 2 | not on the list | +| hypseus/Esh_Aurunmilla/singe/Esh_Aurunmilla/Esh_Aurunmilla | qte.windows | 50 | 283 | 3 | not on the list | +| DragonsLairEnhanced/DLe | qte.windows | 52 | 267 | 4 | not on the list | +| SpaceAceEnhanced/SAe | qte.windows | 52 | 268 | 4 | not on the list | +| FridayThe13th/f13 | qte.windows | 52 | 271 | 4 | not on the list | +| SuckerPunch/SuckerPunch | qte.windows | 54 | 282 | 4 | yes | +| hypseus/hayate_1080/singe/hayate_1080/hayate_1080 | qte.held | 39 | 455 | 4 | not on the list | +| DragonsLair2Enhanced/DL2e | qte.windows | 53 | 266 | 6 | yes | +| hypseus/BD13/singe/BD13/BD13 | qte.held | 41 | 347 | 7 | not on the list | +| ChantzesStone/Chantze | qte.windows | 53 | 268 | 9 | yes | +| ChantzesStone/Triad | qte.windows | 53 | 268 | 9 | yes | + +## Made by another tool + +ActionMax/38AmbushAlley.forge, ActionMax/BlueThunder.forge, ActionMax/Hydrosub2021.forge, ActionMax/PopsGhostly.forge, ActionMax/SonicFury.forge. diff --git a/testScripts/ports/shotDiff.py b/testScripts/ports/shotDiff.py new file mode 100755 index 000000000..92e586e8a --- /dev/null +++ b/testScripts/ports/shotDiff.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# The Picture row's check (FORGE.md 14.18): the screenshots an original and its port took at the +# same moments (compare.sh with PORT_SHOTS=N and SINGE_SHOTS=<dir>), paired by number, and how much +# of each picture differs. A pixel differs when any channel is more than TOLERANCE apart, since +# the two runs decode the same video but may round a scaled frame differently. +# +# testScripts/ports/shotDiff.py <shots dir> [<out dir>] +# +# prints one line a pair and a summary; with <out dir>, writes each pair side by side with the +# differing pixels marked in the third panel, for a person to look at. +import os +import re +import sys + +import numpy +from PIL import Image + +TOLERANCE = 24 # Per channel, out of 255: video rounding, not a missing prompt +MARK = (255, 0, 255) +PAIR = re.compile(r"^(original|port)-shot-((?:run\d+-)?\d+)-(\w+)\.png$") + + +def main(): + shots = sys.argv[1] if len(sys.argv) > 1 else sys.exit("usage: shotDiff.py <shots dir> [<out dir>]") + out = sys.argv[2] if len(sys.argv) > 2 else None + found = {} + + for name in os.listdir(shots): + match = PAIR.match(name) + if match: + found.setdefault((match.group(2), match.group(3)), {})[match.group(1)] = os.path.join(shots, name) + if out: + os.makedirs(out, exist_ok=True) + worst = 0.0 + for (number, why), pair in sorted(found.items()): + if "original" not in pair or "port" not in pair: + print("%s %-6s only the %s took it" % (number, why, "original" if "original" in pair else "port")) + continue + a = numpy.asarray(Image.open(pair["original"]).convert("RGB"), dtype=numpy.int16) + b = numpy.asarray(Image.open(pair["port"]).convert("RGB"), dtype=numpy.int16) + if a.shape != b.shape: + print("%s %-6s sizes differ: %s and %s" % (number, why, a.shape, b.shape)) + continue + differs = (numpy.abs(a - b) > TOLERANCE).any(axis=2) + percent = 100.0 * differs.mean() + worst = max(worst, percent) + print("%s %-6s %6.2f%% of the picture differs" % (number, why, percent)) + if out: + marked = a.copy() + marked[differs] = MARK + side = numpy.concatenate([a, b, marked], axis=1).astype(numpy.uint8) + Image.fromarray(side).save(os.path.join(out, "%s-%s.png" % (number, why))) + print("%d pairs; the worst differs in %.2f%% of its picture" % (len(found), worst)) + + +if __name__ == "__main__": + main() diff --git a/testScripts/ports/sweep.sh b/testScripts/ports/sweep.sh index abff6a424..41e72d78a 100755 --- a/testScripts/ports/sweep.sh +++ b/testScripts/ports/sweep.sh @@ -31,7 +31,7 @@ pair() { familyOf() { local desc=$1 - if grep -a -q 'kind = "rdg"\|kind = "timegal"' "$desc"; then + if grep -a -q 'style = "segments"\|style = "checkpoints"\|style = "trophies"' "$desc"; then echo rdg elif grep -a -q 'kind = "alg"' "$desc"; then echo alg @@ -54,6 +54,11 @@ libraryPair() { alg|timetraveler|videoland) pair "$family" "$folder" "$lib" ;; + rdg) + # A segmented style in the library's own layout: the rdg family knows only the + # Hypseus one, and the karis driver plays every style. + pair karis "$script" "$lib" + ;; *) pair "$family" "$script" "$lib" ;; diff --git a/testScripts/scene56.singe b/testScripts/scene56.singe index b00b3fc9b..f530bc595 100644 --- a/testScripts/scene56.singe +++ b/testScripts/scene56.singe @@ -19,9 +19,14 @@ overlaySetResolution(720, 480) local source = assert(io.open("testScripts/author/platformer.forge", "r")) local copy = assert(io.open(work, "w")) -copy:write(source:read("a")) +-- With credits, so the release can be checked for them: the fields go to games.dat, the notes to +-- CREDITS.txt, and the licence file travels with the game. +copy:write((source:read("a"):gsub("(\n\ttitle%s*=[^\n]*\n)", "%1\tcredits = { developer = \"Test Developer\", year = 1983, notes = { \"Original game: Test Author\", \"Converted by: Test Porter\" }, licenses = { \"LICENSE.txt\" } },\n", 1))) source:close() copy:close() +copy = assert(io.open(out .. "LICENSE.txt", "w")) +copy:write("A licence the release must carry.\n") +copy:close() if not forgeBegin(work) then debugPrint("RELEASE FAIL could not open " .. work) @@ -64,6 +69,26 @@ local function step() end elseif stage == 4 then + -- The original author's credits: in games.dat for the menu, the notes in CREDITS.txt, and + -- the licence beside the game. + local dat = assert(io.open(dest .. "/games.dat", "r")):read("a") + local credits = io.open(dest .. "/CREDITS.txt", "r") + local licence = io.open(dest .. "/LICENSE.txt", "r") + local notes = credits and credits:read("a") or "" + + if credits then + credits:close() + end + if licence then + licence:close() + end + if dat:find('DEVELOPER = "Test Developer"', 1, true) and dat:find("YEAR = 1983", 1, true) and notes:find("Original game: Test Author", 1, true) and (licence ~= nil) then + debugPrint("RELEASE the credits travel: games.dat, CREDITS.txt, and the licence") + else + debugPrint("RELEASE FAIL credits missing from the release") + end + + elseif stage == 5 then -- Whether it truly stands alone cannot be answered from in here: DIR is the directory of -- the script the engine was *launched* with, so a game reached by dofile would look for the -- runtime beside this scene instead of beside itself. A released game is the main script, @@ -76,7 +101,7 @@ end function onOverlayUpdate() frames = frames + 1 - if frames % 14 == 0 and stage < 5 then + if frames % 14 == 0 and stage < 6 then stage = stage + 1 step() end diff --git a/util/forgeCredits.lua b/util/forgeCredits.lua new file mode 100644 index 000000000..f08ca441c --- /dev/null +++ b/util/forgeCredits.lua @@ -0,0 +1,191 @@ +-- The original author's credits for a Forge port of an existing game: the fields of the game's own +-- games.dat entry (developer, publisher, year, platform, genre, creator, source, description), the +-- "KEY: value" lines of its main script's header comment as notes, and the licence files beside +-- the script, which a release carries. Nothing is invented: a field the original does not state +-- is left out. +-- +-- lua5.4 util/forgeCredits.lua <description.forge> <game directory> <main script> +-- +-- writes or replaces the description's credits block, just after its title, and touches nothing +-- else in it. util/forgePortKaris.lua loads this file for creditsFor, so a converted description +-- carries its credits from the start. + +local CREDIT_KEYS = { + { field = "developer", key = "DEVELOPER" }, + { field = "publisher", key = "PUBLISHER" }, + { field = "year", key = "YEAR" }, + { field = "platform", key = "PLATFORM" }, + { field = "genre", key = "GENRE" }, + { field = "creator", key = "CREATOR" }, + { field = "source", key = "SOURCE" }, + { field = "description", key = "DESCRIPTION" } +} +local LICENCE_PATTERNS = { "^copying", "^licen[cs]e", "^gpl" } +local GAMES_DAT_DEPTH = 2 -- How many folders above the script games.dat may be. + + +local function readFile(path) + local handle = io.open(path, "rb") + local text = nil + + if handle then + text = handle:read("a") + handle:close() + end + + return text +end + + +-- The games.dat entry for the script: beside it or a folder or two above, the entry whose SCRIPT +-- names this script, or the only entry there is. +local function gamesEntry(dir, main) + local folder = dir + + for _ = 0, GAMES_DAT_DEPTH do + local text = readFile(folder .. "/games.dat") + + if text then + local sandbox = {} + local chunk = load(text, "games.dat", "t", sandbox) + + if chunk and pcall(chunk) and (type(sandbox.GAMES) == "table") then + for _, entry in ipairs(sandbox.GAMES) do + local script = tostring(entry.SCRIPT or "") + + if script:lower():sub(-#main) == main:lower() then + return entry + end + end + if #sandbox.GAMES == 1 then + return sandbox.GAMES[1] + end + end + end + folder = folder:match("^(.*)[/\\][^/\\]+$") or folder + end + + return nil +end + + +-- The header comment's "KEY: value" lines, as the author wrote them: program name, author, who +-- converted it and for what. +local function headerNotes(dir, main) + local text = readFile(dir .. "/" .. main) or "" + local block = text:match("%-%-%[=*%[(.-)%]=*%]") + local notes = {} + + for line in (block or ""):gmatch("[^\r\n]+") do + local key, value = line:match("^%s*([A-Z][A-Z0-9 /%-]*[A-Z0-9])%s*:%s*(.-)%s*$") + + if key and (value ~= "") then + notes[#notes + 1] = key:sub(1, 1) .. key:sub(2):lower() .. ": " .. value + end + end + + return notes +end + + +-- The licence files beside the script, by name. +local function licences(dir) + local found = {} + local handle = io.popen('ls -1 "' .. dir .. '" 2>/dev/null') + + if handle then + for name in handle:lines() do + for _, pattern in ipairs(LICENCE_PATTERNS) do + if name:lower():match(pattern) then + found[#found + 1] = name + break + end + end + end + handle:close() + end + table.sort(found) + + return found +end + + +-- The credits block as description source, one line, or nil when there is nothing to credit. +function creditsFor(dir, main) + local entry = gamesEntry(dir, main) or {} + local parts = {} + local notes = headerNotes(dir, main) + local files = licences(dir) + + for _, credit in ipairs(CREDIT_KEYS) do + local value = entry[credit.key] + + if type(value) == "number" then + parts[#parts + 1] = string.format("%s = %d", credit.field, value) + elseif (type(value) == "string") and (value ~= "") then + parts[#parts + 1] = string.format("%s = %q", credit.field, value) + end + end + if #notes > 0 then + local quoted = {} + + for _, note in ipairs(notes) do + quoted[#quoted + 1] = string.format("%q", note) + end + parts[#parts + 1] = "notes = { " .. table.concat(quoted, ", ") .. " }" + end + if #files > 0 then + local quoted = {} + + for _, name in ipairs(files) do + quoted[#quoted + 1] = string.format("%q", name) + end + parts[#parts + 1] = "licenses = { " .. table.concat(quoted, ", ") .. " }" + end + if #parts == 0 then + return nil + end + + return "\tcredits = { " .. table.concat(parts, ", ") .. " }," +end + + +-- The description with its credits line put just after the title, replacing one already there. +local function withCredits(text, line) + local out = {} + local after = false + + for row in (text .. "\n"):gmatch("(.-)\n") do + if not row:match("^\tcredits = {") then + out[#out + 1] = row + if (not after) and line and row:match("^\ttitle%s*=") then + out[#out + 1] = line + after = true + end + end + end + while (#out > 0) and (out[#out] == "") do + out[#out] = nil + end + + return table.concat(out, "\n") .. "\n", after +end + + +if arg and arg[0] and arg[0]:match("forgeCredits%.lua$") then + local description = arg[1] or error("a description") + local dir = arg[2] or error("the game's directory") + local main = arg[3] or error("its main script") + local text = readFile(description) or error("cannot read " .. description) + local line = creditsFor(dir, main) + local result, put = withCredits(text, line) + + if line and not put then + error("no title line in " .. description) + end + local handle = io.open(description, "wb") + + handle:write(result) + handle:close() + print(description .. ": " .. (line and line:gsub("^\t", "") or "nothing to credit")) +end diff --git a/util/forgeLedger.py b/util/forgeLedger.py new file mode 100644 index 000000000..4782ddb3b --- /dev/null +++ b/util/forgeLedger.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# The ledger of FORGE.md section 17: every capability the library's converted titles use, which +# titles use it, and how the converter maps it, gathered from util/forgePortKaris.lua's report +# (FORGE_LEDGER) for each description it made. Nothing is run but the converter; no game plays. +# +# python3 util/forgeLedger.py [<out>] (default: testScripts/ports/ledger.md) +# +# SINGE_LIBRARY the ported originals (default: ~/claude/singetest/ported) +# +# A status is the converter's own: "forge" is a field of Forge's model, "framework" something +# Forge plays under the framework's names or numbers, "lua" something the game's own Lua still does. +# The runs-without-its-Lua column is testScripts/ports/standalone.txt as last proved, with its date. +import collections +import datetime +import hashlib +import os +import re +import subprocess +import sys +import tempfile + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +LIBRARY = os.path.expanduser(os.environ.get("SINGE_LIBRARY", "~/claude/singetest/ported")) +OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(REPO, "testScripts", "ports", "ledger.md") +STANDING = os.path.join(REPO, "testScripts", "ports", "standalone.txt") +MADE_BY = "by the port converter" +SCRIPT = re.compile(r'^\s*script\s*=\s*"([^"]+)"', re.M) +STATUSES = ("lua", "framework", "forge") +EXAMPLES = 6 + + +def descriptions(): + """Every description the converter made, as (title, directory, main script, name).""" + found = [] + for root, dirs, files in os.walk(LIBRARY): + dirs.sort() + for name in sorted(f for f in files if f.endswith(".forge")): + path = os.path.join(root, name) + text = open(path, errors="replace").read() + if MADE_BY not in text: + continue + script = SCRIPT.search(text) + if not script: + continue + title = os.path.relpath(path, LIBRARY)[:-len(".forge")] + found.append((title, root, script.group(1), name[:-len(".forge")])) + return found + + +def others(): + """The descriptions another tool made, named so the ledger says what it does not cover.""" + missing = [] + for root, dirs, files in os.walk(LIBRARY): + for name in files: + path = os.path.join(root, name) + if name.endswith(".forge") and MADE_BY not in open(path, errors="replace").read(): + missing.append(os.path.relpath(path, LIBRARY)) + return sorted(missing) + + +def addonsHash(directory): + path = os.path.join(directory, "Script", "addons.singe") + return hashlib.md5(open(path, "rb").read()).hexdigest() if os.path.exists(path) else None + + +def report(directory, script, name): + with tempfile.NamedTemporaryFile(suffix=".ledger", delete=False) as handle: + path = handle.name + try: + done = subprocess.run(["lua5.4", os.path.join("util", "forgePortKaris.lua"), directory, script, name], + cwd=REPO, env=dict(os.environ, FORGE_LEDGER=path), capture_output=True, text=True, timeout=120) + lines = [line.rstrip("\n").split("\t") for line in open(path) if line.strip()] + return lines, (done.stderr.strip().splitlines() or [""])[-1] if done.returncode else None + finally: + os.remove(path) + + +def main(): + standing = set() + proved = "never" + if os.path.exists(STANDING): + standing = {line.strip() for line in open(STANDING) if line.strip() and not line.startswith("#")} + proved = datetime.date.fromtimestamp(os.path.getmtime(STANDING)).isoformat() + + uses = collections.defaultdict(set) # (status, capability) -> titles + details = collections.defaultdict(set) # (status, capability) -> details + perTitle = {} + failed = [] + titles = descriptions() + # The add-ons most titles ship unchanged are the framework's own: their hooks are the stock + # ones, not the game's Lua. + hashes = collections.Counter(h for h in (addonsHash(row[1]) for row in titles) if h) + stock = hashes.most_common(1)[0][0] if hashes else None + for title, directory, script, name in titles: + lines, error = report(directory, script, name) + if error is not None: + failed.append((title, error)) + continue + if stock and addonsHash(directory) == stock: + lines = [["framework", capability, "stock add-ons"] if capability.startswith("hook.") else [status, capability, detail] for status, capability, detail in lines] + counts = collections.Counter() + family = "?" + for status, capability, detail in lines: + uses[(status, capability)].add(title) + if detail: + details[(status, capability)].add(detail) + counts[status] += 1 + if capability.startswith("family."): + family = capability[len("family."):] + folder = title.split("/")[1] if title.startswith("hypseus/") else title.split("/")[0] + perTitle[title] = (family, counts, "yes" if folder in standing else "not on the list") + + out = [] + out.append("# The ledger (FORGE.md section 17)") + out.append("") + out.append("Generated by `util/forgeLedger.py` on %s from the converter's report for each" % datetime.date.today().isoformat()) + out.append("description it made; do not edit by hand. **forge** is a field of Forge's own model,") + out.append("**framework** something Forge plays under the framework's names or numbers, **lua**") + out.append("something the game's own Lua still does. Rows are sorted by status, then by how many") + out.append("titles use them: the top of the table is what most blocks titles from being Forge's own.") + out.append("A hook from the add-ons file most titles ship unchanged (%d of them) is the framework's" % (hashes[stock] if stock else 0)) + out.append("stock one, and counts as framework. The globals are every global the game's script left") + out.append("that nothing names: most are the framework's own state and picture numbers, not data.") + out.append("") + out.append("%d titles reported; %d failed to report; %d descriptions made by another tool (not covered)." % (len(perTitle), len(failed), len(others()))) + out.append("") + + # The capabilities, with the globals and settings folded into one row each per status: there + # are hundreds, and their names are listed in the section after. + folded = collections.defaultdict(set) + for (status, capability), titles in uses.items(): + if capability.startswith("global.") or capability.startswith("setting."): + folded[(status, capability.split(".")[0] + ".*")] |= titles + else: + folded[(status, capability)] |= titles + out.append("## Capabilities") + out.append("") + out.append("| Status | Capability | Titles | Some of them | Details |") + out.append("|---|---|---|---|---|") + for (status, capability), titles in sorted(folded.items(), key=lambda item: (STATUSES.index(item[0][0]), -len(item[1]), item[0][1])): + names = sorted(titles) + more = ", ..." if len(names) > EXAMPLES else "" + shown = ", ".join(title.split("/")[-1] for title in names[:EXAMPLES]) + more + found = sorted(details.get((status, capability), [])) + note = "; ".join(found[:8]) + ("; ..." if len(found) > 8 else "") + out.append("| %s | %s | %d | %s | %s |" % (status, capability, len(names), shown, note)) + out.append("") + + for prefix, heading in (("setting.", "Settings under the framework's names"), ("global.", "Globals nothing names yet")): + rows = sorted(((capability, len(titles)) for (status, capability), titles in uses.items() if capability.startswith(prefix)), key=lambda row: (-row[1], row[0])) + out.append("## %s (%d)" % (heading, len(rows))) + out.append("") + out.append("By how many titles use each: " + ", ".join("%s %d" % (capability[len(prefix):], count) for capability, count in rows) + ".") + out.append("") + + out.append("## Titles") + out.append("") + out.append("Runs without its Lua: `standalone.txt`, last proved %s." % proved) + out.append("") + out.append("| Title | Family | forge | framework | lua | Runs without its Lua |") + out.append("|---|---|---|---|---|---|") + for title, (family, counts, standalone) in sorted(perTitle.items(), key=lambda item: (item[1][1]["lua"], item[1][1]["framework"], item[0])): + out.append("| %s | %s | %d | %d | %d | %s |" % (title, family, counts["forge"], counts["framework"], counts["lua"], standalone)) + out.append("") + if failed: + out.append("## Failed to report") + out.append("") + for title, error in failed: + out.append("* %s: %s" % (title, error)) + out.append("") + out.append("## Made by another tool") + out.append("") + out.append(", ".join(others()) + ".") + out.append("") + + with open(OUT, "w") as handle: + handle.write("\n".join(out)) + print("wrote %s: %d titles, %d capabilities, %d failed" % (OUT, len(perTitle), len(folded), len(failed))) + + +if __name__ == "__main__": + main() diff --git a/util/forgePortKaris.lua b/util/forgePortKaris.lua index f530b351e..53df5266f 100644 --- a/util/forgePortKaris.lua +++ b/util/forgePortKaris.lua @@ -25,6 +25,12 @@ local dir = arg[1] or error("a game directory") local main = arg[2] or error("the main script") local name = arg[3] or main:gsub("%.singe$", "") +-- A game that draws at random as it loads (a scene swap, a level's variant) draws the same every +-- time, so a description made twice is made the same. +local CONVERT_SEED = 12345 + +math.randomseed(CONVERT_SEED) + -- ===== Loading the game's script ============================================================== local env = setmetatable({}, { __index = _G }) @@ -221,13 +227,12 @@ assert(loadfile(dir .. "/" .. main, "t", env))() -- ===== The settings =========================================================================== --- The dips, by name, from what the game was last saved with or shipped with: the framework --- reads Cfg/game.cfg and falls back to Cfg/default.cfg. -local function settings() +-- The dips, by name, from the game's config files in Cfg/, the first to name one winning. +local function configFiles(names) local found = {} - for _, file in ipairs({ dir .. "/Cfg/game.cfg", dir .. "/Cfg/default.cfg" }) do - local handle = io.open(file, "r") + for _, name in ipairs(names) do + local handle = io.open(dir .. "/Cfg/" .. name, "r") if handle then for line in handle:lines() do @@ -244,6 +249,17 @@ local function settings() return found end +-- What the game was last saved with or shipped with: the framework reads Cfg/game.cfg and falls +-- back to Cfg/default.cfg. +local function settings() + return configFiles({ "game.cfg", "default.cfg" }) +end + +-- What the service screen's "Default" restores: Cfg/default.cfg alone. +local function defaults() + return configFiles({ "default.cfg" }) +end + -- ===== The moves ============================================================================== local function copy(t) @@ -380,10 +396,24 @@ end local out = {} +-- The original author's credits, from the game's own games.dat and script header (forgeCredits.lua +-- beside this file), so a port names whoever made the game. +dofile((arg[0]:match("^(.*[/\\])") or "") .. "forgeCredits.lua") + + local function put(line) out[#out + 1] = line end + +local function putCredits() + local line = creditsFor(dir, main) + + if line then + put(line) + end +end + local function lua(value, indent) indent = indent or "" if type(value) == "table" then @@ -426,19 +456,35 @@ end local levels = {} --- A map-mode game's levels are functions of its main.singe, run at play; nothing to read here. -for index = 1, (rdg and 0 or env.finalstage) do - local level = env.Level[index] - - local play, playBy = scenesOf(index) - - levels[index] = { title = level[1], intro = level[2], introEnd = level[3], scenes = level[4], mirror = level[5], deathMirror = level[6], replay = level[7], play = play, playBy = playBy } +-- A level row as the description keeps it: the one place its fields are written down, the +-- runtime building the loops' positional rows from it. play and playBy are its scenes, for a +-- level the game can reach. +local function levelOf(level, play, playBy) + return { title = level[1], intro = level[2], introEnd = level[3], scenes = level[4], mirror = level[5], deathMirror = level[6], replay = level[7], selectStill = level[8], play = play, playBy = playBy } end -if (not rdg) and env.AllowSecret and env.Level[env.levelSecret] then - local level = env.Level[env.levelSecret] - local play, playBy = scenesOf(env.levelSecret) - levels.secret = { title = level[1], intro = level[2], introEnd = level[3], scenes = level[4], mirror = level[5], deathMirror = level[6], replay = level[7], play = play, playBy = playBy } +-- Every row the game declares, those past its last stage and a secret one it cannot reach +-- included, since the game's own Lua may read them. A map-mode game's levels are functions of +-- its main.singe, run at play; nothing to read here. +if not rdg then + for index, level in pairs(env.Level) do + if (type(index) == "number") and (index ~= env.levelSecret) then + local play, playBy = nil, nil + + if index <= env.finalstage then + play, playBy = scenesOf(index) + end + levels[index] = levelOf(level, play, playBy) + end + end + if env.Level[env.levelSecret] then + local play, playBy = nil, nil + + if env.AllowSecret then + play, playBy = scenesOf(env.levelSecret) + end + levels.secret = levelOf(env.Level[env.levelSecret], play, playBy) + end end local tiers = {} @@ -548,16 +594,29 @@ end -- The high score board the game ships with (Cfg/hscore.cfg: ten lines of NAME,score): a score -- that reaches it goes to the board's screens at the end. -local function highScores() - local found = {} +-- The game's boards from Cfg/hscore.cfg, as the framework reads them: ten lines each, a blank +-- line between, for the normal game, the life-bar game, and the survivor game; then, after the +-- trophy counts, each level's best percent ("name,percent!difficulty"). +local function boards() + local found = { normal = {}, lifeBar = {}, survivor = {}, percent = {} } + local order = { "normal", "lifeBar", "survivor" } local handle = io.open(dir .. "/Cfg/hscore.cfg", "r") + local which = 1 if handle then for line in handle:lines() do - local who, score = line:match("^%s*([^,]+),(%d+)") + local who, score = line:match("^%s*([^,]+),(%d+)%s*$") + local board = found[order[which]] - if who and (#found < 10) then - found[#found + 1] = { who, tonumber(score) } + local best, percent, difficulty = line:match("^%s*([^,]+),(%d+)!(%a)%s*$") + + if board and who then + board[#board + 1] = { who, tonumber(score) } + if #board == 10 then + which = which + 1 + end + elseif best then + found.percent[#found.percent + 1] = { best, tonumber(percent), difficulty } end end handle:close() @@ -647,6 +706,107 @@ for key, value in pairs(env) do end end end +-- A table literal of Author.singe's, by its local's name: the runtime's own names and constants, +-- read from the runtime so they are written once. +local authorSource = nil + +local function authorLiteral(name) + if authorSource == nil then + local path = (arg[0]:match("^(.*[/\\])") or "") .. "../forge/Author.singe" + local handle = io.open(path, "rb") or error("cannot read " .. path) + + authorSource = handle:read("a") + handle:close() + end + local body = authorSource:match("\nlocal " .. name .. " = (%b{})") or error("no " .. name .. " in Author.singe") + + return load("return " .. body, name, "t", {})() +end + +-- The clips and stills under Forge's names, out of the settings: the names are Author.singe's +-- own QTE_CLIPS table, read from there so they are written once; a still is a frame global +-- named by what follows "frame"; the attract steps are offsetIntro01, 02, and on. +local clips, stills = {}, {} + +do + local names = authorLiteral("QTE_CLIPS") + + if not rdg then + for _, clip in ipairs(names) do + if settings[clip[2]] ~= nil then + clips[clip[1]] = { settings[clip[2]], settings[clip[3]] } + settings[clip[2]] = nil + settings[clip[3]] = nil + end + end + local attract = {} + + while settings[string.format("offsetIntro%02d", #attract + 1)] ~= nil do + local step = #attract + 1 + + attract[step] = { settings[string.format("offsetIntro%02d", step)], settings[string.format("offsetIntro%02dend", step)] } + settings[string.format("offsetIntro%02d", step)] = nil + settings[string.format("offsetIntro%02dend", step)] = nil + end + if #attract > 0 then + clips.attract = attract + end + for key, value in pairs(settings) do + local name = key:match("^frame(%u%w*)$") + + if name and (type(value) == "number") then + stills[name:sub(1, 1):lower() .. name:sub(2)] = value + settings[key] = nil + end + end + end +end + +-- The other named fields, by Author.singe's QTE_FIELDS: each value leaves the settings for its +-- group (scoring, lifeBar, windowDelay, order, deaths, secret, video). +local groups = {} + +if not rdg then + for _, field in ipairs(authorLiteral("QTE_FIELDS")) do + local group, name = field[1]:match("^(%w+)%.(%w+)$") + + if settings[field[2]] ~= nil then + groups[group] = groups[group] or {} + groups[group][name] = settings[field[2]] + settings[field[2]] = nil + end + end +end + +-- A setting equal to the constant the runtime lays down before the settings (Author.singe's QTE +-- table, and HELD over it for a held game) is a copy of it, and is left out. +do + local constants = {} + + for _, tableName in ipairs(kimmy and { "QTE", "HELD" } or { "QTE" }) do + for key, value in pairs(authorLiteral(tableName)) do + if (key ~= "FLAG_OF") and (key ~= "BRANCH") then + constants[key] = value + end + end + end + for key, value in pairs(settings) do + if (type(value) ~= "table") and (constants[key] == value) then + settings[key] = nil + end + end +end + +-- The segmented lineage's runtime reads no settings (its game's own Lua declares them all), so +-- its descriptions carry none (see the qte table below). +-- One source for each: the levels are in levels, and a dip is in dips (the runtime lays the dips +-- over the settings, so a copy here would only look like it mattered). +if not rdg then + settings.Level = nil +end +for key in pairs(dips or {}) do + settings[key] = nil +end -- (A game that sets its rate by number rather than by name has no MovieFPS; under the -- hand-written games' catch-all stubs the name answers as a function, which is not data.) settings.MovieFPS = (type(env.MovieFPS) == "number") and env.MovieFPS or nil @@ -664,40 +824,277 @@ end -- to them and not to the game's declarations -- a snapshot that carried them would describe a -- state the game never starts in. A file that will not load under the stubs is no loss; the -- game still names it and the loop still runs it. +-- The functions they define or replace are the hooks the loop calls into the game's own Lua, which +-- the ledger names (FORGE_LEDGER, below). +local hooks = {} + do local chunk = loadfile(dir .. "/Script/addons.singe", "t", env) if chunk then + local before = {} + + for key, value in pairs(env) do + before[key] = value + end pcall(chunk) + -- A hook whose body is only blank lines and comments is a stub, and does nothing. + local source = {} + local handle = io.open(dir .. "/Script/addons.singe", "rb") + + for line in (handle and handle:read("a") or ""):gmatch("([^\n]*)\n?") do + source[#source + 1] = line + end + if handle then + handle:close() + end + for key, value in pairs(env) do + if (type(value) == "function") and (before[key] ~= value) then + local info = debug.getinfo(value, "S") + local body = {} + + for at = info.linedefined + 1, info.lastlinedefined - 1 do + body[#body + 1] = (source[at] or ""):gsub("%-%-.*$", "") + end + -- The one-line form, "function name() end", has its body on its own line. + if info.linedefined == info.lastlinedefined then + body[1] = (source[info.linedefined] or ""):gsub("%-%-.*$", ""):gsub("^.-%b()", ""):gsub("end%s*$", "") + end + hooks[#hooks + 1] = { name = key, empty = not table.concat(body, " "):find("%S") } + end + end + table.sort(hooks, function(a, b) return a.name < b.name end) end end +-- Where the games the qte behaviour plays differ, the description says which way, by name +-- (FORGE.md 15.4): the runtime never asks which framework or version a game came from. Only +-- the qte loop reads these; the other loops' games carry none. +local function optionsFor() + if alg or timetraveler then + return nil + end + local options = {} + + -- How the qte behaviour judges and runs this game; the default, windows, is left unsaid. + options.style = (timegal and "checkpoints") or (sdq and "trophies") or (rdg and "segments") or (kimmy and "held") or nil + + if frameworkVersion == "3.32b" then + options.gameOverAfterContinue = "seek" + options.secretInputs = { "p1BUTTON4", "p1UP" } + end + if mazescater then + options.hintSeconds = 4 + options.alternateMashRate = 6 + options.mashAnySwitch = true + options.doubleKind = 34 + end + + return (next(options) ~= nil) and options or nil +end + +local shipped = defaults() + local qte = { - script = main, - addons = (not rdg) and "Script/addons.singe" or nil, - framework = frameworkVersion, - hsDR = kimmy and dieAndRetry() or nil, - saves = kimmy and saves() or nil, - settings = settings, - dips = dips, - swaps = (not rdg) and swaps() or nil, - highScores = rdg and RDG_BOARD or highScores(), - tweaks = rdg and RDG_TWEAKS[name] or nil, - sdq = sdq and sdqOrder() or nil, - percents = sdq and RDG_EXTRA or nil, - flows = alg and algLevels() or nil, - levels = (not rdg) and levels or nil + script = main, + addons = (not rdg) and "Script/addons.singe" or nil, + options = optionsFor(), + hsDR = kimmy and dieAndRetry() or nil, + saves = (not rdg) and saves() or nil, + settings = (not rdg) and settings or nil, + clips = next(clips) and clips or nil, + stills = next(stills) and stills or nil, + scoring = groups.scoring, + lifeBar = groups.lifeBar, + windowDelay = groups.windowDelay, + order = groups.order, + deaths = groups.deaths, + secret = groups.secret, + video = groups.video, + dips = dips, + defaults = (not rdg) and next(shipped) and shipped or nil, + swaps = (not rdg) and swaps() or nil, + highScores = rdg and RDG_BOARD or nil, + boards = (not rdg) and boards() or nil, + tweaks = rdg and RDG_TWEAKS[name] or nil, + trophyOrder = sdq and sdqOrder() or nil, + percents = sdq and RDG_EXTRA or nil, + flows = alg and algLevels() or nil, + levels = (not rdg) and levels or nil } +-- ===== The ledger =============================================================================== +-- +-- FORGE_LEDGER=<file> writes, instead of the description, what the title uses and how this +-- converter maps each thing (FORGE.md section 17), one line each: a status, a capability, and a +-- detail. "forge" is a field of Forge's own model; "framework" is something Forge plays under +-- the framework's names or numbers (a dip, a leftover global, a move kind, a style); "lua" is +-- something the game's own Lua still does. util/forgeLedger.py gathers these over the library. + +-- The judge (FORGE.md 15.4) each framework move kind is, by the kind's name in Author.singe's QTE +-- table, or HELD's for a held game. +local KIND_JUDGES = { + UP = "press.direction", DOWN = "press.direction", LEFT = "press.direction", RIGHT = "press.direction", + BUTTON1 = "press.button", BUTTON2 = "press.button", BUTTON3 = "press.button", BUTTON4 = "press.button", + UPLEFT = "press.diagonal", UPRIGHT = "press.diagonal", DOWNLEFT = "press.diagonal", DOWNRIGHT = "press.diagonal", + ACTUP = "chord.buttonDirection", ACTDOWN = "chord.buttonDirection", ACTLEFT = "chord.buttonDirection", ACTRIGHT = "chord.buttonDirection", + COMBO = "chord", MASH = "mash.one", MASHMIN = "mash.one", MASHMAX = "mash.one", + MASH2 = "mash.two", MASH2MIN = "mash.two", MASH2MAX = "mash.two", RUN = "mash.run", RUNMIN = "mash.run", RUNMAX = "mash.run", + HOLDUP = "hold.direction", HOLDDOWN = "hold.direction", HOLDLEFT = "hold.direction", HOLDRIGHT = "hold.direction", + HOLDBUT = "hold.button", HOLD = "hold", LETGO = "letGo", DOUBLE = "double", MULTI = "count", + LOOP = "circle", LOOPLEFT = "circle", LOOPRIGHT = "circle", UD = "sequence", DU = "sequence", LR = "sequence", RL = "sequence", B1B2 = "sequence", + CHOOSE = "choose", PATH = "path", YESNO = "yesNo", TIMED = "timed", MTIMED = "timed.multi", + ANYTHING = "anything", NOTHING = "nothing", SKIP = "skip", + WAY = "jump", WAYOUT = "jump", TOLEVEL = "jump", TOSCENE = "jump" +} + + +local function ledger(path) + local lines = {} + local seen = {} + + local function use(status, capability, detail) + local key = status .. "\t" .. capability .. "\t" .. (detail or "") + + if not seen[key] then + seen[key] = true + lines[#lines + 1] = key + end + end + + local family = (videoland and "videoland") or (timetraveler and "timetraveler") or (alg and "alg") or ("qte." .. ((qte.options and qte.options.style) or "windows")) + + use("forge", "family." .. family, frameworkVersion) + -- The moves: every row's kind, as the judge it is. + local names = {} + + for _, tableName in ipairs(kimmy and { "QTE", "HELD" } or { "QTE" }) do + for key, value in pairs(authorLiteral(tableName)) do + if KIND_JUDGES[key] and (type(value) == "number") then + names[value] = key + end + end + end + local function moves(value) + for key, item in pairs(value) do + if (key == "moves") and (type(item) == "table") then + for _, row in ipairs(item) do + if (type(row) == "table") and (type(row[3]) == "number") then + local kind = names[row[3]] + + if kind then + use("framework", "judge." .. KIND_JUDGES[kind], kind) + else + -- Not a kind Forge names: the framework's own constants of that value, + -- as candidates for what it is. + local candidates = {} + + for key, value in pairs(env) do + if (value == row[3]) and (type(key) == "string") and key:match("^%u[%u%d]+$") then + candidates[#candidates + 1] = key + end + end + table.sort(candidates) + use("framework", "judge.unnamed", "kind " .. row[3] .. ((#candidates > 0) and (" (" .. table.concat(candidates, " or ") .. ")") or "")) + end + end + end + elseif type(item) == "table" then + moves(item) + end + end + end + moves(levels) + if qte.swaps then + use("forge", "scene.swaps") + end + -- The screens and the named groups. + for clip in pairs(clips) do + use("forge", "screen." .. clip) + end + for still in pairs(stills) do + use("forge", "still." .. still) + end + for group, fields in pairs(groups) do + for field in pairs(fields) do + use("forge", group .. "." .. field) + end + end + -- Persistence. + if qte.boards or qte.highScores then + use("forge", "persist.boards") + end + if qte.saves then + use("forge", "persist.saves") + end + if qte.hsDR then + use("framework", "persist.dieAndRetryRecords") + end + -- What is still the framework's: its settings, the globals nothing names, its style, and the + -- per-title tweaks and tables of the map-mode lineage. + for dip in pairs(dips or {}) do + use("framework", "setting." .. dip) + end + for key in pairs(qte.settings or {}) do + use("framework", "global." .. key) + end + for option in pairs(qte.options or {}) do + use((option == "style") and "framework" or "forge", "option." .. option, (option == "style") and qte.options.style or nil) + end + for tweak in pairs(qte.tweaks or {}) do + use("framework", "tweak." .. tweak) + end + if qte.trophyOrder then + use("framework", "order.trophies") + end + if qte.percents then + use("framework", "scoring.percents") + end + if qte.flows then + use("framework", "flows.handWritten", main:gsub("%.singe$", "") .. ".levels.lua") + end + -- What the game's own Lua still does. + if alg or videoland or timetraveler then + use("lua", "vocabulary", "Vocabulary.singe") + elseif rdg then + use("lua", "levelFunctions", main) + end + -- swapScene is run here, over every level, scene, and difficulty, into scene.swaps (see swaps). + for _, hook in ipairs(hooks) do + if hook.empty then + use("framework", "hook." .. hook.name, "empty") + elseif hook.name == "swapScene" then + use("forge", "scene.swaps", "read from swapScene") + else + use("lua", "hook." .. hook.name, "Script/addons.singe") + end + end + if qte.script and not (alg or videoland or timetraveler) then + use("lua", "script", qte.script) + end + table.sort(lines) + local handle = assert(io.open(path, "w")) + + handle:write(table.concat(lines, "\n") .. "\n") + handle:close() +end + + +if os.getenv("FORGE_LEDGER") then + ledger(os.getenv("FORGE_LEDGER")) + return +end + if videoland then -- The objects are set up by the game's own reset; the rooms, furniture, and directions -- stand as the script declares them. env.resetGameData() - put("-- " .. name .. ", ported to Forge from its Adventures in Videoland script by util/forgePortKaris.lua:") + put("-- " .. name .. ", ported to Forge from its original script by the port converter:") put("-- the game's rooms, objects, and furniture as data for the videoland behaviour, which plays its loop") put("-- under the console look; both live in Vocabulary.singe beside this file.") put("return {") put("\ttitle = " .. lua(name) .. ",") + putCredits() put("\tvocabulary = \"Vocabulary.singe\",") put("\tplayers = 1,") put("\tlayers = { { kind = \"disc\" }, { kind = \"overlay\" } },") @@ -724,12 +1121,13 @@ if videoland then return end -local loop = (timetraveler and "timetraveler") or (alg and "alg") or (timegal and "timegal" or (sdq and "sdq" or (rdg and "rdg" or (kimmy and "kimmy" or "qte")))) +local loop = (timetraveler and "timetraveler") or (alg and "alg") or "qte" -put("-- " .. (rawget(env, "Title") or name) .. ", ported to Forge from its " .. (timetraveler and "Time Traveler (Singe Edition)" or alg and "American Laser Games" or (timegal and "Time Gal (Singe Edition)" or (rdg and "map-mode" or (kimmy and "Kimmy Script Engine" or (mazescater and "MazescaterFramework" or "KarisFramework"))))) .. " script by util/forgePortKaris.lua:") -put("-- the framework's tables as data for the " .. loop .. " behaviour, which plays the framework's game loop.") +put("-- " .. (rawget(env, "Title") or name) .. ", ported to Forge from its original script by the port converter:") +put("-- the game's tables as data for the " .. loop .. " behaviour, which plays the game's loop.") put("return {") put("\ttitle = " .. lua(name) .. ",") +putCredits() if alg then -- The title's mechanics and screens are a vocabulary beside its script. put("\tvocabulary = \"Vocabulary.singe\",") @@ -739,11 +1137,27 @@ put("\tlayers = { { kind = \"disc\" }, { kind = \"overlay\" } },") put("\tvars = { score = 0, lives = 0, credits = 0, level = 0, prompt = \"\"" .. (kimmy and ", deaths = 0, lifeBar = 0, tilt = 0" or "") .. " },") put("") put("\ttypes = {") --- No look: the loops expose score, lives, credits, level and prompt as vars for a game's own --- hud layer to bind, and draw none of the game's own screens. The hud and <loop>Hud looks this --- line used to name belong to the drawing work the owner parked (FORGE.md 14.15) and are in no --- manifest, so naming one only made the runtime complain about a look it does not have. -put("\t\tgame = { look = { kind = \"none\" }, behaviours = { { kind = \"" .. loop .. "\" } } }") +-- The qte behaviour's games draw their HUD with the qteHud look (FORGE.md 14.15), from the game's +-- own pictures and font; the other loops' games draw none of it yet. +put("\t\tgame = { look = { kind = \"" .. ((loop == "qte") and "qteHud" or "none") .. "\" }, behaviours = { { kind = \"" .. loop .. "\" } } }") +-- The HUD's second font (the options' Font row other than the game's own) is the framework's +-- Media/Font/default.ttf, which the port cannot reach: it travels as the title's Fonts/hudDefault.ttf. +if loop == "qte" then + local font, found = frameworkFile:gsub("/Script/globals%.singe$", "/Media/Font/default.ttf") + local from = (found == 1) and io.open(font, "rb") or nil + local into = env.MYDIR .. "/Fonts/hudDefault.ttf" + + if from and not io.open(into, "rb") then + local bytes = from:read("a") + local handle = io.open(into, "wb") or error("cannot write " .. into) + + handle:write(bytes) + handle:close() + end + if from then + from:close() + end +end put("\t},") put("") put("\trooms = {") diff --git a/util/forgeStandalone.py b/util/forgeStandalone.py index ac62cd88b..d22f16cc4 100644 --- a/util/forgeStandalone.py +++ b/util/forgeStandalone.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # Which Forge ports play from their description alone, with none of the game's own Lua? # -# The qte and kimmy behaviours load only what the description names, so deleting its "script" and +# The qte behaviour's windows and held styles load only what the description names, so deleting its "script" and # "addons" lines leaves the loop nothing but the snapshot the converter wrote. This runs each # framework title that way, against its original, through the same comparison the ports are proved # with (FORGE.md 14.1), and writes the ones that agree to testScripts/ports/standalone.txt. @@ -34,7 +34,7 @@ HOSTED = re.compile(r'^\s*(script|addons)\s*=\s*"[^"]*",\s*$') def titles(): - """Every description the qte or kimmy behaviour plays, as (name, family, game argument, + """Every description the qte behaviour plays, as (name, family, game argument, description path). The library's own are a directory and a script; a Hypseus title is its folder under hypseus/.""" found = [] @@ -43,7 +43,10 @@ def titles(): path = os.path.join(root, name) text = open(path, errors="replace").read() loop = re.search(r'behaviours = \{ \{ kind = "(\w+)" \} \}', text) - if not loop or loop.group(1) not in ("qte", "kimmy"): + if not loop or loop.group(1) != "qte": + continue + # The segmented styles run the game's own level functions every play: never standalone. + if re.search(r'style = "(segments|checkpoints|trophies)"', text): continue script = re.search(r'^\s*script\s*=\s*"([^"]+)",', text, re.M) if not script: