From 34fc72ae5df5790f9df84f5338df8b91e1d11016 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Tue, 22 Sep 2026 18:32:07 -0500 Subject: [PATCH] More work on the menu system and Forge. --- CHANGELOG | 125 +- LICENSES | 12 + assets/Backdrop.singe | 24 +- assets/Forge/Author.singe | 8871 ++++++++++++++++- assets/Forge/AuthorCompile.singe | 34 +- assets/Forge/Forge.singe | 124 +- ...01-tenMinutes.game => 01-tenMinutes.forge} | 0 ...02-platformer.game => 02-platformer.forge} | 0 .../{03-sprites.game => 03-sprites.forge} | 0 .../{04-shmup.game => 04-shmup.forge} | 0 .../tutorials/{05-qte.game => 05-qte.forge} | 0 .../tutorials/{06-gun.game => 06-gun.forge} | 0 .../{07-adventure.game => 07-adventure.forge} | 0 .../{08-scene.game => 08-scene.forge} | 0 .../tutorials/{09-rail.game => 09-rail.forge} | 326 +- .../{10-release.game => 10-release.forge} | 0 assets/Menu.rml | 14 +- assets/MenuDocument.singe | 20 +- assets/MenuOverlay.singe | 246 +- assets/Tools.singe | 61 +- assets/menu.rcss | 169 +- assets/menuBackground.mkv | 4 +- assets/menuIntro.flac | 4 +- assets/samples/dragonFireBreathAndRoar.flac | 3 + assets/samples/explosionUltraBass.flac | 3 + assets/samples/largeFireball.flac | 3 + cmake/Superbuild.cmake | 4 +- docs/Forge.adoc | 209 +- docs/ForgeVocabulary.adoc | 52 +- docs/Manual.adoc | 39 +- src/ktx2.h | 3 +- src/ktx2Basis.cpp | 2 + src/render.c | 5 + src/render.h | 7 + src/renderGles.c | 44 +- src/renderGlesApi.h | 7 + src/renderGlesLoad.c | 2 + src/scene.c | 10 +- src/singe.c | 79 +- src/videoPlayer.c | 46 +- testScripts/Menu.sh | 4 + testScripts/README.md | 1 + .../{adventure2d.game => adventure2d.forge} | 0 .../{adventure3d.game => adventure3d.forge} | 2 +- .../author/{bowling.game => bowling.forge} | 0 .../author/{breakout.game => breakout.forge} | 0 testScripts/author/{fmv.game => fmv.forge} | 0 testScripts/author/{fps.game => fps.forge} | 0 .../author/{gunvideo.game => gunvideo.forge} | 0 testScripts/author/mash.forge | 34 + testScripts/author/{maze.game => maze.forge} | 0 testScripts/author/overlayBits.forge | 47 + .../author/{painted.game => painted.forge} | 0 .../{platformer.game => platformer.forge} | 0 .../{platformer3d.game => platformer3d.forge} | 0 testScripts/author/{pool.game => pool.forge} | 0 testScripts/author/{qte.game => qte.forge} | 0 .../author/{racing.game => racing.forge} | 0 .../{railshooter.game => railshooter.forge} | 0 .../author/{rhythm.game => rhythm.forge} | 0 .../author/{shmup.game => shmup.forge} | 0 .../author/{space.game => space.forge} | 0 testScripts/author/{tail.game => tail.forge} | 0 .../{thirdperson.game => thirdperson.forge} | 0 .../{towerdefence.game => towerdefence.forge} | 0 testScripts/author/vocab.forge | 32 + testScripts/author/vocab.singe | 47 + .../author/{waves.game => waves.forge} | 0 testScripts/chip.nsf | 3 + testScripts/crate.jxl | 3 + testScripts/games.dat | 11 + testScripts/ports/README | 30 + testScripts/ports/actionMaxInputs.singe | 36 + testScripts/ports/actionMaxOriginal.singe | 52 + testScripts/ports/actionMaxPort.singe | 52 + testScripts/ports/algDrive.singe | 263 + testScripts/ports/algOriginal.singe | 515 + testScripts/ports/algPort.singe | 503 + testScripts/ports/compare.sh | 208 + testScripts/ports/karisDrive.singe | 498 + testScripts/ports/karisOriginal.singe | 106 + testScripts/ports/karisPort.singe | 101 + testScripts/ports/launch.sh | 49 + testScripts/ports/shots.sh | 22 + testScripts/ports/sweep.sh | 68 + testScripts/ports/timetravelerDrive.singe | 200 + testScripts/ports/timetravelerOriginal.singe | 66 + testScripts/ports/timetravelerPort.singe | 63 + testScripts/ports/videolandDrive.singe | 120 + testScripts/ports/videolandOriginal.singe | 58 + testScripts/ports/videolandPort.singe | 57 + testScripts/scene52.singe | 4 +- testScripts/scene53.singe | 2 +- testScripts/scene54.singe | 8 +- testScripts/scene55.singe | 4 +- testScripts/scene56.singe | 6 +- testScripts/scene58.singe | 2 +- testScripts/scene59.singe | 2 +- testScripts/scene60.singe | 4 +- testScripts/scene61.singe | 2 +- testScripts/scene62.singe | 2 +- testScripts/scene63.singe | 4 +- testScripts/scene64.singe | 2 +- testScripts/scene65.singe | 2 +- testScripts/scene66.singe | 2 +- testScripts/scene67.singe | 2 +- testScripts/scene68.singe | 2 +- testScripts/scene69.singe | 4 +- testScripts/scene70.singe | 2 +- testScripts/scene71.singe | 2 +- testScripts/scene72.singe | 2 +- testScripts/scene73.singe | 2 +- testScripts/scene74.singe | 2 +- testScripts/scene75.singe | 2 +- testScripts/scene76.singe | 2 +- testScripts/scene77.singe | 2 +- testScripts/scene78.singe | 2 +- testScripts/scene79.singe | 2 +- testScripts/scene80.singe | 4 +- testScripts/scene81.singe | 2 +- testScripts/scene82.singe | 4 +- testScripts/scene83.singe | 4 +- testScripts/scene92.singe | 4 +- testScripts/scene94.singe | 2 +- testScripts/scene96.singe | 62 + testScripts/scene97.singe | 85 + testScripts/scene98.singe | 56 + testScripts/scene99.singe | 63 + testScripts/tracker.it | 3 + testScripts/tracker.s3m | Bin 0 -> 624 bytes testScripts/tracker.xm | 3 + testScripts/tutorialDrive.singe | 10 +- thirdparty/SDL3_image/src/IMG_libpng.c | 14 +- util/forgePortActionMax.py | 294 + util/forgePortKaris.lua | 699 ++ util/forgePorts.py | 271 + util/makeMenuSound.py | 221 +- util/renderMenuAd.py | 137 + util/shootMenu.py | 189 + 139 files changed, 15296 insertions(+), 708 deletions(-) rename assets/Forge/tutorials/{01-tenMinutes.game => 01-tenMinutes.forge} (100%) rename assets/Forge/tutorials/{02-platformer.game => 02-platformer.forge} (100%) rename assets/Forge/tutorials/{03-sprites.game => 03-sprites.forge} (100%) rename assets/Forge/tutorials/{04-shmup.game => 04-shmup.forge} (100%) rename assets/Forge/tutorials/{05-qte.game => 05-qte.forge} (100%) rename assets/Forge/tutorials/{06-gun.game => 06-gun.forge} (100%) rename assets/Forge/tutorials/{07-adventure.game => 07-adventure.forge} (100%) rename assets/Forge/tutorials/{08-scene.game => 08-scene.forge} (100%) rename assets/Forge/tutorials/{09-rail.game => 09-rail.forge} (99%) rename assets/Forge/tutorials/{10-release.game => 10-release.forge} (100%) create mode 100644 assets/samples/dragonFireBreathAndRoar.flac create mode 100644 assets/samples/explosionUltraBass.flac create mode 100644 assets/samples/largeFireball.flac create mode 100755 testScripts/Menu.sh rename testScripts/author/{adventure2d.game => adventure2d.forge} (100%) rename testScripts/author/{adventure3d.game => adventure3d.forge} (98%) rename testScripts/author/{bowling.game => bowling.forge} (100%) rename testScripts/author/{breakout.game => breakout.forge} (100%) rename testScripts/author/{fmv.game => fmv.forge} (100%) rename testScripts/author/{fps.game => fps.forge} (100%) rename testScripts/author/{gunvideo.game => gunvideo.forge} (100%) create mode 100644 testScripts/author/mash.forge rename testScripts/author/{maze.game => maze.forge} (100%) create mode 100644 testScripts/author/overlayBits.forge rename testScripts/author/{painted.game => painted.forge} (100%) rename testScripts/author/{platformer.game => platformer.forge} (100%) rename testScripts/author/{platformer3d.game => platformer3d.forge} (100%) rename testScripts/author/{pool.game => pool.forge} (100%) rename testScripts/author/{qte.game => qte.forge} (100%) rename testScripts/author/{racing.game => racing.forge} (100%) rename testScripts/author/{railshooter.game => railshooter.forge} (100%) rename testScripts/author/{rhythm.game => rhythm.forge} (100%) rename testScripts/author/{shmup.game => shmup.forge} (100%) rename testScripts/author/{space.game => space.forge} (100%) rename testScripts/author/{tail.game => tail.forge} (100%) rename testScripts/author/{thirdperson.game => thirdperson.forge} (100%) rename testScripts/author/{towerdefence.game => towerdefence.forge} (100%) create mode 100644 testScripts/author/vocab.forge create mode 100644 testScripts/author/vocab.singe rename testScripts/author/{waves.game => waves.forge} (100%) create mode 100644 testScripts/chip.nsf create mode 100644 testScripts/crate.jxl create mode 100644 testScripts/ports/README create mode 100644 testScripts/ports/actionMaxInputs.singe create mode 100644 testScripts/ports/actionMaxOriginal.singe create mode 100644 testScripts/ports/actionMaxPort.singe create mode 100644 testScripts/ports/algDrive.singe create mode 100644 testScripts/ports/algOriginal.singe create mode 100644 testScripts/ports/algPort.singe create mode 100755 testScripts/ports/compare.sh create mode 100644 testScripts/ports/karisDrive.singe create mode 100644 testScripts/ports/karisOriginal.singe create mode 100644 testScripts/ports/karisPort.singe create mode 100755 testScripts/ports/launch.sh create mode 100755 testScripts/ports/shots.sh create mode 100755 testScripts/ports/sweep.sh create mode 100644 testScripts/ports/timetravelerDrive.singe create mode 100644 testScripts/ports/timetravelerOriginal.singe create mode 100644 testScripts/ports/timetravelerPort.singe create mode 100644 testScripts/ports/videolandDrive.singe create mode 100644 testScripts/ports/videolandOriginal.singe create mode 100644 testScripts/ports/videolandPort.singe create mode 100644 testScripts/scene96.singe create mode 100644 testScripts/scene97.singe create mode 100644 testScripts/scene98.singe create mode 100644 testScripts/scene99.singe create mode 100644 testScripts/tracker.it create mode 100644 testScripts/tracker.s3m create mode 100644 testScripts/tracker.xm create mode 100644 util/forgePortActionMax.py create mode 100644 util/forgePortKaris.lua create mode 100644 util/forgePorts.py create mode 100644 util/renderMenuAd.py create mode 100644 util/shootMenu.py diff --git a/CHANGELOG b/CHANGELOG index fc726113f..0bcc0874c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -7,6 +7,10 @@ SINGE 3.00 API Changes ----------- +- onInputPressed and onInputReleased receive a second argument when the + switch came from a mouse or a light gun: the device's index, as + Hypseus passes it, so a two-gun game can tell its players apart. A + switch from the keyboard or a pad passes nothing there. - singeScreenshot takes an optional file name, so a script that makes pictures on purpose can name them. @@ -21,6 +25,22 @@ API Changes - In MODE_FULL a pad or mouse button reaches the script as the switch it is mapped to, as the manual always said; it arrived as a keysym of 0. +- A sprite scaled to nothing (a bullet shrinking to zero) draws as a + pixel instead of ending the engine with "Unable to transform sprite". +- --deterministic stepped the disc one video frame per engine frame, + whatever the step, and the overlay throttle's strict comparison ran + the script every other iteration of the virtual clock, so a game that + reads the same frame on two consecutive updates (as a game paced for + a faster overlay does) never saw it twice; the disc now follows the + virtual clock like everything else, each frame lasting the time it is + worth, and the script runs every step. A bare os.time() answers + with the virtual clock too, so a game that seeds its generator from + the wall clock when a game starts (the one-player Space Pirates) + repeats; os.time with a date table is Lua's own. +- fontSelect given no font (nil, or an index that was never loaded) + keeps the current font and warns instead of ending the script, as + Hypseus lets it pass; The Last Bounty Hunter names a font it never + loaded at its first board. - guiSetHandler set again after a document's elements were rebuilt (a list through inner_rml) now attaches to the new element with that id. @@ -58,7 +78,42 @@ API Changes by count and interval at a moment, a disc frame, or a rail camera's stop, from an entity or a list of them in turn, or at a point; and soundDone says when a clip playSound or a sound behaviour started has - ended. Flat + ended. Ports of the test library began (FORGE.md section 14): the + five ActionMax games are Forge descriptions written by + util/forgePortActionMax.py, and play the same as the emulator under + the same inputs (testScripts/ports/). On the way: a lightSensor + behaviour, a pointer behaviour, fonts and anchors on the text look, + sprites drawn in copies, discPlay and discPause, pointerX and pointerY + in expressions, an event belonging to the room it happened in, and a + built game finding files beside its description. A game can bring a + vocabulary of its own: a description names a file of Lua + (vocabulary = "Vocabulary.singe") that adds looks, behaviours, + conditions, actions, layers, or events to the manifest for that game + alone; the editor loads it when the game opens and forgets it when + the game closes, the compiler checks against it, and the built game + carries it into a release. The ActionMax light sensor lives in one + now, beside those five games, and not in Forge. The Hypseus library's + eleven Karis Framework 3.32b titles (Arcade Xperience 1 to 3, Astroboy, + Danmachi, Freddy, Starship Troopers, Sugar Rush, Survival, Esh's + Aurunmilla, and Badlands Lite) are ported by the same converter, which + finds the framework where a game keeps it and notes its version; the + rest of that library is surveyed in FORGE.md 14.9. A branch on the + branching track can want its move mashed (mash = 6) or held (hold = + 20). The seven Kimmy Script Engine titles (Brain Dead 13, Future Boy + Conan, Mazinga Z, Fuma Conspiracy, Sonic 1996, Sintel, Ninja Hayate + 1080) are ported by the same converter and a kimmy behaviour that + plays that engine's loop, and play as their originals do (FORGE.md + 14.11). + util/forgePorts.py + assembles the ported games into a project folder of their own + (~/claude/singePorts), one game to a folder with its description, the + compiled port, the runtime, its files, and a games.dat. A description is a + .forge file now (it was .game, which is the packed release's, and the + chooser had to sniff which was which). The seventeen KarisFramework + games are ported too: util/forgePortKaris.lua reads a game's own + script, and the qte behaviour plays the framework's loop over the + framework's tables, running the game's script and add-ons in a shim; + every one plays as its original under the same driver. Flat bodies are the size their looks are drawn at (they were half), and a platformer stands on its point. @@ -494,8 +549,12 @@ API Changes - Compressed textures. KTX2 files (Basis Universal, from util/compressTextures.py) stay compressed on the GPU in the block - format the machine has (BC7, ASTC, BC3, or plain RGBA where nothing - else exists), a quarter of the memory of PNGs or less. A model whose + format the machine has (BC7, ASTC, ETC2, BC3, or plain RGBA where + nothing else exists), a quarter of the memory of PNGs or less. ETC2 + is the one block format OpenGL ES 3 guarantees, so a handheld's Mali + without ASTC keeps its textures compressed rather than falling to RGBA + on the machine with the least memory; the GLES backend takes ETC2 + always and ASTC 4x4 where the driver offers it. A model whose file uses KHR_texture_basisu loads them on its own; materialSetTexture and the map calls take a .ktx2 name in place of a sprite; sceneGetStats reports the texture memory in use. The Sponza test scene went from @@ -1113,6 +1172,25 @@ API Changes Fixes ----- +- The menu's grid, horizon and sun were drawn black and unlit through the + intro, which kept the lights off them but not the blast's glow behind + them: a black line across a flash is a line, and the whole grid showed + through the explosion as its outline. They are not drawn at all until + the grid comes up out of the dark. The recording of the backdrop is + re-made with it. + +- A PNG with 16-bit samples and no alpha channel (crate.png in the test + scripts is one) drew with coloured fringes: SDL_image put libpng's + big-endian 48-bit pixels into a surface the machine reads + little-endian, swapping only the 64-bit kind, so every sample's low + byte was taken for its colour. The vendored loader swaps both now. + +- musicPlay answers the handle it was given, as Hypseus's does. Its + games keep that (sndHandle = musicPlay(music)) to stop the piece, and + three of them (Freddy, Starship Troopers, Sugar Rush) died on the map + screen with "musicStop: Argument 1 must be a number" because Singe's + answered nothing. musicStop takes a nil handle as none. + - A machine with no enumerable mouse keeps its pointer. ManyMouse reads /dev/input/event* directly where there is no X server, and those nodes are root:input, so an account that was never added to the "input" group @@ -1151,13 +1229,40 @@ Fixes clips the old video was cut from are gone, one of which nobody could account for. -- The menu has a sound. util/makeMenuSound.py writes it -- every noise in - it is an oscillator, a burst of noise or an envelope over one of the two, - in a room that is a convolution with a tail of decaying noise, so there is - nothing in it that cannot be accounted for either. The charge, the rush - of the logo arriving, the wingbeats (on the same curve the animation flaps - to), the flame, and a riser as the grid comes up, over a bed whose four - bars land exactly where the menu takes the screen. +- util/renderMenuAd.py records the menu's intro with its sound as one + H.264 file at any size and rate (1920x1080 at 50 fps by default), for + use outside the engine; the backdrop's tagline is laid out in step with + the overlay it is drawn on, so a larger recording gets a larger tagline + rather than the menu's scaled up. + +- The service tools are dressed like the rest of the menu. Each tool was + a sheet of white text on black; it is now a card over the moving + backdrop in the game page's own colours -- the panel gradient, the + blue-grey border, the title in gold on the selection's gradient bar, a + chosen row on the same bar, and the key hints in a footer strip. The + list of tools says what the chosen tool is for in a column beside the + list rather than under it, and the two tools that draw their own + picture (Light Gun and Display) keep a strip of the card along the + bottom, out of the way of the targets. Both renderers draw it the same + way: the RmlUi document, and the plain overlay a machine without a GPU + falls back to. Rows line up: a tool no longer pads columns into its + text with spaces, which a proportional font never honoured; a pair is + a label column and a value column, and a list item carries its detail + (a size, a state, a field's value) against the right edge of the row. + util/shootMenu.py runs the menu headless through both renderers and + shoots every page into screenshots/menu, with a contact sheet each. + +- The menu has a sound. util/makeMenuSound.py writes it. The three things + the picture shows are recordings now: the charge is Mark DiAngelo's + "Explosion Ultra Bass", the logo bursting out of it Mike Koenig's "Large + Fireball", and the dragon Daniel Simon's "Dragon Fire Breath and Roar", + laid so the moment its roar turns to fire is the moment the animation + breathes -- three sounds from SoundBible under Creative Commons + Attribution 3.0, kept under assets/samples and credited in LICENSES. + The rest is still made in the script -- the wingbeats (on the same curve + the animation flaps to), a riser as the grid comes up, the logo leaving, + and a bed whose four bars land exactly where the menu takes the screen -- + from oscillators and noise, in a room that is a convolution. The charge and the flame are built the way the things themselves are heard rather than the way they are drawn. Both were made of smooth envelopes over steady noise -- the flame of a band of noise wobbled by two diff --git a/LICENSES b/LICENSES index edea0cbeb..c959b4288 100644 --- a/LICENSES +++ b/LICENSES @@ -69,6 +69,18 @@ Fonts FreeSansBold GPL-3.0 with font exception https://www.gnu.org/software/freefont +Sounds +------ + +The menu's intro (assets/menuIntro.flac, written by util/makeMenuSound.py) is built on three +recordings from SoundBible, each under the Creative Commons Attribution 3.0 licence +(https://creativecommons.org/licenses/by/3.0/) and kept as FLAC under assets/samples: + +Explosion Ultra Bass Mark DiAngelo CC BY 3.0 https://soundbible.com/1807-Explosion-Ultra-Bass.html +Large Fireball Mike Koenig CC BY 3.0 https://soundbible.com/1348-Large-Fireball.html +Dragon Fire Breath and Roar Daniel Simon CC BY 3.0 https://soundbible.com/2127-Dragon-Fire-Breath-and-Roar.html + + Data ---- diff --git a/assets/Backdrop.singe b/assets/Backdrop.singe index 7c29b552e..cf2b7b567 100644 --- a/assets/Backdrop.singe +++ b/assets/Backdrop.singe @@ -135,6 +135,8 @@ local backdropTagX = 0 local backdropTagY = 0 local blown = false -- Whether the charge has gone off. local gridLit = false -- Whether the grid has finished coming up and its colours are final. +local 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 -- ===== The backdrop ========================================================================== @@ -425,12 +427,14 @@ local grid = nodeNew() nodeSetMesh(grid, gridMesh(), gridMat) nodeSetParent(grid, backdrop) +backdropParts[#backdropParts + 1] = grid local horizon = nodeNew() nodeSetMesh(horizon, meshPlane(HORIZ_W * 2, HORIZ_H), horizMat) nodeSetRotation(horizon, 90, 0, 0) nodeSetPosition(horizon, 0, horizonY(HORIZ_Z), HORIZ_Z) nodeSetParent(horizon, backdrop) +backdropParts[#backdropParts + 1] = horizon local sunCut = horizonY(SUN_Z) - SUN_Y local backSun = nodeNew() @@ -438,6 +442,7 @@ local backSun = nodeNew() nodeSetMesh(backSun, sunMesh(sunCut), sunMat2) nodeSetPosition(backSun, 0, SUN_Y, SUN_Z) nodeSetParent(backSun, backdrop) +backdropParts[#backdropParts + 1] = backSun for n = 1, SUN_SLICES do local slice = nodeNew() @@ -448,6 +453,7 @@ for n = 1, SUN_SLICES do nodeSetMesh(slice, meshPlane(w * 2, height), sliceMat) nodeSetRotation(slice, 90, 0, 0) nodeSetPosition(slice, 0, SUN_Y + y, SUN_Z + 0.4) + backdropParts[#backdropParts + 1] = slice nodeSetParent(slice, backdrop) end @@ -483,6 +489,15 @@ function backdropFrame() -- there is no moment where one thing has ended and the next has not begun. local lit = span(t, GRID_UP, GRID_LIT) + -- Until then the grid, the horizon and the sun are not drawn at all. Black and unlit, they + -- were still there: a black line across the blast's glow is a line, and the whole grid showed + -- through the explosion as its outline. + if (lit > 0) and not gridShown then + gridShown = true + for _, part in ipairs(backdropParts) do + nodeSetVisible(part, true) + end + end if (lit > 0) and not gridLit then materialSetColor(gridMat, 255 * lit, 45 * lit, 190 * lit) materialSetColor(horizMat, 255 * lit, 150 * lit, 235 * lit) @@ -566,7 +581,10 @@ end function backdropBegin(showIntro) -- A font of its own. The document renderer draws everything else through RmlUi and never -- selects one, so the tagline -- the only thing put straight into the overlay -- brings it. - backdropFont = fontLoad("Singe/FreeSansBold.ttf", 30) + -- Thirty points on the menu's 480 rows, and in step with the overlay elsewhere: a recording + -- made at a larger size (util/renderMenuAd.py) lays the tagline out at that size rather than + -- scaling the menu's up. + backdropFont = fontLoad("Singe/FreeSansBold.ttf", math.floor(30 * overlayGetHeight() / 480 + 0.5)) fontSelect(backdropFont) fontQuality(FONT_QUALITY_BLENDED) @@ -582,6 +600,10 @@ function backdropBegin(showIntro) backdropStarted = singeGetTicks() - (showIntro and 0 or (LIFT_TO * 1000)) blown = not showIntro gridLit = false + gridShown = false + for _, part in ipairs(backdropParts) do + nodeSetVisible(part, false) + end if not showIntro then nodeSetVisible(logo, false) end diff --git a/assets/Forge/Author.singe b/assets/Forge/Author.singe index c0468037a..c4c6b1327 100644 --- a/assets/Forge/Author.singe +++ b/assets/Forge/Author.singe @@ -123,6 +123,7 @@ local sequences = {} -- Running coroutines, by rule and instance. local timers = {} -- Runtime timers: { instance, name, at, every }. local onceSeen = {} -- once tags, by scope. local sounds = {} -- Loaded clips by file. +local fonts = {} -- Loaded fonts by file and size. local playing = {} -- What soundPlay is playing, by channel: the file and the instance that played it, for soundDone. local sprites = {} -- Loaded images by file. local pointers = {} -- Injected pointer positions by player, for tests. @@ -351,8 +352,8 @@ end -- A key named in a behaviour's parameters, as the engine delivers it. Rules have theirs compiled; --- behaviours get the description's own text and resolve it here. -local function keyValue(name) +-- behaviours get the description's own text and resolve it here. Public, for a game's own vocabulary. +function authorKeyValue(name) if type(name) == "string" then return SCANCODE[name] and SCANCODE[name].value or nil end @@ -362,7 +363,7 @@ end -- A switch named the same way. -local function switchValue(name) +function authorSwitchValue(name) if type(name) == "string" then return _G[name] end @@ -371,19 +372,20 @@ local function switchValue(name) end --- A picture loaded once per file -- or, with a frame count, as a sheet of that many frames across, --- which is its own sprite since the engine keeps the frames with the handle. --- Where a file the description names really is. A game built in the editor names files as the --- engine sees them from the games directory (Forge/kit/hero.png); a released game carries its --- files beside itself and names them from there (kit/hero.png), and the compiled script says --- where it is in AUTHOR_DIR. The name is tried beside the game first, then as it is. -function authorFile(name) - if (AUTHOR_DIR or "") ~= "" and not name:match("^[/\\]") and not name:match("^%a:") then - local probe = io.open(AUTHOR_DIR .. name, "rb") +-- A file of the game's own -- its vocabulary, a script it brought -- beside the description +-- first, then beside the built script: a built script can share a name with a file of the +-- game's, and loading the built one would be loading the game inside itself. +function authorBeside(name) + if not name:match("^[/\\]") and not name:match("^%a:") then + for _, folder in ipairs({ AUTHOR_SOURCE_DIR or "", AUTHOR_DIR or "" }) do + if folder ~= "" then + local probe = io.open(folder .. name, "rb") - if probe ~= nil then - probe:close() - return AUTHOR_DIR .. name + if probe ~= nil then + probe:close() + return folder .. name + end + end end end @@ -391,6 +393,82 @@ function authorFile(name) end +-- A game's own vocabulary: a file of Lua that adds looks, behaviours, conditions, actions, +-- layers, or events to the manifest for this game alone, written as the manifest's own are. +-- The description names it (vocabulary = "Vocabulary.singe"); the runtime, the compiler, and +-- the editor each load it before they read the manifest, and a release carries it. What it +-- added is remembered so the editor can forget it when the game is closed. +AUTHOR_ADDED = {} + +function authorVocabulary(name, folder) + local path = (folder and (folder ~= "") and (io.open(folder .. name, "rb") ~= nil)) and (folder .. name) or authorBeside(name) + local had = {} + + for set, entries in pairs(AUTHOR) do + had[set] = {} + for key in pairs(entries) do + had[set][key] = true + end + end + local chunk, why = loadfile(path) + + if chunk == nil then + debugPrint("Author: vocabulary " .. tostring(name) .. " did not load: " .. tostring(why)) + return false + end + local ok, err = xpcall(chunk, debug.traceback) + + if not ok then + debugPrint("Author: vocabulary " .. tostring(name) .. ": " .. tostring(err):sub(1, 800)) + return false + end + for set, entries in pairs(AUTHOR) do + for key in pairs(entries) do + if not (had[set] and had[set][key]) then + AUTHOR_ADDED[#AUTHOR_ADDED + 1] = { set = set, key = key } + end + end + end + + return true +end + + +-- Takes out what a game's vocabulary added, when the editor closes the game. +function authorVocabularyForget() + for _, added in ipairs(AUTHOR_ADDED) do + if AUTHOR[added.set] then + AUTHOR[added.set][added.key] = nil + end + end + AUTHOR_ADDED = {} +end + + +-- Where a file the description names really is. A game built in the editor names files as the +-- engine sees them from the games directory (Forge/kit/hero.png); a released game carries its +-- files beside itself and names them from there (kit/hero.png), and the compiled script says +-- where it is in AUTHOR_DIR. The name is tried beside the game first, then as it is. +function authorFile(name) + if not name:match("^[/\\]") and not name:match("^%a:") then + for _, folder in ipairs({ AUTHOR_DIR or "", AUTHOR_SOURCE_DIR or "" }) do + if folder ~= "" then + local probe = io.open(folder .. name, "rb") + + if probe ~= nil then + probe:close() + return folder .. name + end + end + end + end + + return name +end + + +-- A picture loaded once per file -- or, with a frame count, as a sheet of that many frames across, +-- which is its own sprite since the engine keeps the frames with the handle. local function spriteFor(file, frames) local key = file .. "#" .. tostring(frames or 1) @@ -415,6 +493,17 @@ local function soundFor(file) end +function fontFor(file, size) + local key = file .. "@" .. size + + if fonts[key] == nil then + fonts[key] = fontLoad(authorFile(file), size) + end + + return fonts[key] +end + + -- ===== Layers ================================================================================= -- -- Each declares what it needs of the engine. Nothing here knows what kind of game is being made. @@ -559,8 +648,8 @@ AUTHOR.looks.box = { } AUTHOR.looks.sprite = { - help = "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.", - params = { file = "file", frames = "number", anchor = "string", faces = "string" }, + help = "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.", + params = { file = "file", frames = "number", anchor = "string", faces = "string", step = "number" }, choices = { anchor = { "feet" }, faces = { "right", "left", "none" } }, load = function(instance) instance.sprite = spriteFor(instance.look.file, instance.look.frames) @@ -611,7 +700,12 @@ AUTHOR.looks.sprite = { elseif (frames > 1) or (scale ~= 1) then spriteDrawFrame(instance.sprite, x - w * scale / 2, top, frame, scale) else - spriteDraw(instance.sprite, x - w / 2, top) + local copies = instance.vars.copies or 1 + local step = instance.look.step or w + + for copy = 0, copies - 1 do + spriteDraw(instance.sprite, x - w / 2 + copy * step, top) + end end end } @@ -649,30 +743,56 @@ AUTHOR.looks.particles = { } AUTHOR.looks.text = { - help = "A line of text, its top left at the instance. The var text is what it says.", - params = { text = "string", r = "number", g = "number", b = "number" }, + help = "A line of text, its top left at the instance -- or centred on it, standing on it (feet), or hanging from it (top). The var text is what it says. A font file and size of its own, or the game's.", + params = { text = "string", r = "number", g = "number", b = "number", font = "file", size = "number", anchor = "string" }, + choices = { anchor = { "centre", "feet", "top" } }, load = function(instance) instance.vars.text = instance.vars.text or instance.look.text or "" + if instance.look.font then + instance.font = fontFor(instance.look.font, instance.look.size or AUTHOR_FONT_POINTS) + end end, size = function(instance) -- Measured once per change: rendering text to measure it is not free. if instance.measured ~= instance.vars.text then - local sprite = fontToSprite(instance.vars.text ~= "" and instance.vars.text or " ") + local sprite + if instance.font then + fontSelect(instance.font) + end + sprite = fontToSprite(instance.vars.text ~= "" and instance.vars.text or " ") instance.textW = spriteGetWidth(sprite) instance.textH = spriteGetHeight(sprite) instance.measured = instance.vars.text spriteUnload(sprite) + if instance.font then + fontSelect(AUTHOR_FONT) + end end return instance.textW, instance.textH end, draw = function(instance) - local x, y = authorPosition(instance) + local x, y = authorPosition(instance) + local w, h = authorSize(instance) + local anchor = instance.look.anchor + if anchor == "centre" then + x, y = x - w / 2, y - h / 2 + elseif anchor == "feet" then + x, y = x - w / 2, y - h + elseif anchor == "top" then + x = x - w / 2 + end colorForeground(instance.look.r or 255, instance.look.g or 255, instance.look.b or 255, 255) if instance.vars.text ~= "" then + if instance.font then + fontSelect(instance.font) + end fontPrint(x, y, instance.vars.text) + if instance.font then + fontSelect(AUTHOR_FONT) + end end end } @@ -1171,11 +1291,11 @@ AUTHOR.behaviours.keys = { params = { player = "number", left = "scancode", right = "scancode", up = "scancode", down = "scancode", fire = "scancode" }, attach = function(instance, params) instance.keys = { - left = keyValue(params.left), - right = keyValue(params.right), - up = keyValue(params.up), - down = keyValue(params.down), - fire = keyValue(params.fire) + left = authorKeyValue(params.left), + right = authorKeyValue(params.right), + up = authorKeyValue(params.up), + down = authorKeyValue(params.down), + fire = authorKeyValue(params.fire) } instance.vars.dx = 0 instance.vars.dy = 0 @@ -1345,8 +1465,8 @@ AUTHOR.behaviours.spawner = { } AUTHOR.behaviours.sound = { - help = "A clip per event: spawn, hit, death, or any event the instance sees. Values are file names. volume is the clips' own, 0 to 100, under the game's.", - params = { spawn = "file", hit = "file", death = "file", pressed = "file", volume = "number" }, + help = "A clip per event: spawn, hit, miss, death, fire (a gun's trigger), or any event the instance sees. Values are file names. volume is the clips' own, 0 to 100, under the game's.", + params = { spawn = "file", hit = "file", miss = "file", death = "file", fire = "file", pressed = "file", volume = "number" }, attach = function(instance, params) instance.sounds = params if params.spawn then @@ -1368,12 +1488,26 @@ AUTHOR.behaviours.gun = { choices = { reload = { "offscreen" }, aim = { "centre" } }, attach = function(instance, params) instance.gun = params - instance.trigger = switchValue(params.trigger) + instance.trigger = authorSwitchValue(params.trigger) instance.vars.ammo = instance.vars.ammo or params.ammo or 6 instance.vars.player = params.player or 1 end } +AUTHOR.behaviours.pointer = { + help = "Follows a player's pointer: a crosshair, a hand. Hidden when the engine says a real gun wants no crosshair drawn.", + params = { player = "number" }, + attach = function(instance, params) + instance.pointerOf = params.player or 1 + end, + step = function(instance) + local x, y = authorPointer(instance.pointerOf) + + authorMoveTo(instance, x, y) + instance.visible = singeWantsCrosshairs() + end +} + AUTHOR.behaviours.target = { help = "Something a gun can hit. In 2D its look's box; in 3D a body the size of its look, plus zones -- boxes on named bones, so a shot says which part it hit.", params = { zones = "table" }, @@ -1686,7 +1820,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. Raises branchTaken and branchMissed.", + 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.", params = { track = "track" }, attach = function(instance, params) instance.branching = params @@ -1704,13 +1838,28 @@ AUTHOR.behaviours.branching = { if instance.branchAt ~= branch then instance.branchAt = branch instance.decided = false - authorEvent("branchOpen", { self = instance, move = branch.move }) + instance.mashed = 0 + instance.held = 0 + authorEvent("branchOpen", { self = instance, move = branch.move, mash = branch.mash, hold = branch.hold }) end if not instance.decided then - local key = keyValue(branch.move) - local switch = switchValue(branch.switch) + local key = authorKeyValue(branch.move) + local switch = authorSwitchValue(branch.switch) + local made = (key and authorKeyPressed(key)) or (switch and pressedNow[switch]) + local down = (key and AUTHOR_KEYS[key]) or (switch and AUTHOR_SWITCHES[switch]) - if (key and authorKeyPressed(key)) or (switch and pressedNow[switch]) then + if branch.mash then + -- Mash: the move so many times before the window closes. + if made then + instance.mashed = instance.mashed + 1 + end + made = (instance.mashed >= branch.mash) + elseif branch.hold then + -- Hold: the move kept down for so many frames of the window. + instance.held = down and (instance.held + 1) or 0 + made = (instance.held >= branch.hold) + end + if made then instance.decided = true if branch.success then discSkipToFrame(branch.success) @@ -1832,7 +1981,7 @@ AUTHOR.behaviours.hotspot = { } AUTHOR.behaviours.hitbox = { - help = "The instance's hit shape comes from a track of boxes keyed by disc frame or time; between keys it is interpolated, outside them there is none.", + help = "The instance's hit shape comes from a track of boxes keyed by disc frame or time; between keys it is interpolated, outside them there is none. A track with step = true keeps a box only on the frames it keys.", params = { track = "track" }, attach = function(instance, params) instance.hitTrack = params.track @@ -1866,7 +2015,7 @@ AUTHOR.events.stopped = { help = "A rail camera reached a stop; event.name AUTHOR.events.railEnd = { help = "A rail camera reached the end of its track.", filter = {} } AUTHOR.events.verb = { help = "A verb was used on a hotspot (self), with an item or not: \"use key on door\". The most specific rule wins.", filter = { verb = "string", target = "string", item = "string" }, exclusive = true } AUTHOR.events.said = { help = "A line was typed at the parser: its verb, noun, and second noun as the words table knows them, or unknown for a word it does not. The most specific rule wins.", filter = { verb = "string", noun = "string", second = "string" }, exclusive = true } -AUTHOR.events.branchOpen = { help = "A branch's window opened; event.move says what it wants.", filter = {} } +AUTHOR.events.branchOpen = { help = "A branch's window opened; event.move says what it wants, event.mash how many times, event.hold for how many frames.", filter = {} } AUTHOR.events.branchTaken = { help = "The move was made in time and the disc went to the success frame.", filter = {} } AUTHOR.events.branchMissed = { help = "The window closed without the move and the disc went to the fail frame.", filter = {} } AUTHOR.events.midi = { help = "A MIDI note came in on the port the game opened; event.pitch and event.velocity say which and how hard. (pitch, since note is what a rule's comment is called.)", filter = { pitch = "number" } } @@ -2156,6 +2305,18 @@ AUTHOR.actions.discTo = { emit = function(p) return "discSkipToFrame(" .. p.frame .. ")" end } +AUTHOR.actions.discPlay = { + help = "Play the disc from where it stands.", + params = {}, + emit = function() return "discPlay()" end +} + +AUTHOR.actions.discPause = { + help = "Hold the disc on its frame: a still behind a title.", + params = {}, + emit = function() return "discPause()" end +} + AUTHOR.actions.gameOver = { help = "End the game: rules stop, the gameOver event fires, restart begins again.", params = {}, @@ -3022,9 +3183,12 @@ function authorMidi(status, data1, data2) end -function authorMouseMoved(x, y, xr, yr) +function authorMouseMoved(x, y, xr, yr, device) AUTHOR_MOUSE.dx = AUTHOR_MOUSE.dx + (xr or 0) AUTHOR_MOUSE.dy = AUTHOR_MOUSE.dy + (yr or 0) + -- A game loop that hears everything gets the pointer too (a map-mode game that shoots), + -- with the device it moved on, for a game of two guns. + authorFan("pointer", { x = x, y = y, xrel = xr, yrel = yr, device = device }) end @@ -3848,7 +4012,9 @@ local function matches(rule, event) if rule.each and (event.self ~= nil) and (event.self.type ~= rule.each) then return false end - if rule.room and ((AUTHOR_ROOM == nil) or (AUTHOR_ROOM.name ~= rule.room)) then + -- An event belongs to the room it happened in: a press that sends the game to the next room + -- is not also a press in that room. + if rule.room and (event.room ~= rule.room) then return false end @@ -3859,6 +4025,17 @@ end -- Something happened. event carries self, other, and whatever the event has to say. An -- exclusive event -- a verb, a typed line -- runs only the most specific rules that match: "use -- key on door" beats "use anything on door" beats "use". +-- An event about nothing in particular -- a press, a room, the disc -- reaches the behaviours +-- that asked to hear everything: a game loop reading the switches. +function authorFan(name, event) + for _, instance in ipairs(AUTHOR_LIVE) do + if instance.alive and instance.hearsAll then + authorBehaviourEvent(instance, name, event) + end + end +end + + function authorEvent(name, event) local list = rules[name] local entry = AUTHOR.events[name] @@ -3866,8 +4043,11 @@ function authorEvent(name, event) if gameOver and (name ~= "gameOver") and (name ~= "pressed") then return end + event.room = event.room or (AUTHOR_ROOM and AUTHOR_ROOM.name) if event.self then authorBehaviourEvent(event.self, name, event) + else + authorFan(name, event) end if not rulesReady then pending[#pending + 1] = { name = name, event = event } @@ -3929,12 +4109,15 @@ end local function runFrameRules() local list = rules.frame + local room = AUTHOR_ROOM and AUTHOR_ROOM.name if (list == nil) or gameOver then return end + -- The frame belongs to the room it began in: a rule that leaves the room does not hand the + -- rest of the frame to the next room's rules. for _, rule in ipairs(list) do - if (rule.room == nil) or (AUTHOR_ROOM and AUTHOR_ROOM.name == rule.room) then + if (rule.room == nil) or (room == rule.room) then if rule.each then for _, instance in ipairs(authorEach(rule.each)) do runRule(rule, instance, nil, {}) @@ -4138,6 +4321,21 @@ end -- ===== Pointers and guns ====================================================================== -- A player's pointer in overlay coordinates, and whether it is on the picture. A test can set it. +-- The pointer's place, for expressions: pointerX(1) < 360. +function authorPointerX(player) + local x = authorPointer(player) + + return x +end + + +function authorPointerY(player) + local _, y = authorPointer(player) + + return y +end + + function authorPointer(player) local device = (player or 1) - 1 @@ -4315,6 +4513,16 @@ function authorTrackBoxAt(track, at) local keys = track.boxes or {} 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. + if track.step then + for _, key in ipairs(keys) do + if key.at == at then + return { x = key.x, y = key.y, w = key.w, h = key.h } + end + end + return nil + end for _, key in ipairs(keys) do if key.at <= at and ((before == nil) or (key.at > before.at)) then before = key @@ -4492,13 +4700,14 @@ local function updateDisc() end local frame = discGetFrame() local list = rules.frameReached + local room = AUTHOR_ROOM and AUTHOR_ROOM.name if list and (lastFrame >= 0) and (frame > lastFrame) then for _, rule in ipairs(list) do local want = rule.filter.frame - if want and (want > lastFrame) and (want <= frame) then - runRule(rule, nil, nil, { frame = frame }) + if want and (want > lastFrame) and (want <= frame) and ((rule.room == nil) or (room == rule.room)) then + runRule(rule, nil, nil, { frame = frame, room = room }) end end end @@ -4812,7 +5021,11 @@ function authorSwitchIsKey(what) end -function authorSwitchDown(what) +-- The engine names the device a switch came from (a mouse or gun by its index); a press with +-- one is that player's, and only that player's gun fires on its trigger. +function authorSwitchDown(what, device) + local player = device and (device + 1) or nil + if authorSwitchIsKey(what) then return end @@ -4822,7 +5035,7 @@ function authorSwitchDown(what) return end for _, instance in ipairs(AUTHOR_LIVE) do - if instance.alive and instance.gun and (what == (instance.trigger or MOUSE_LEFT)) then + if instance.alive and instance.gun and (what == (instance.trigger or MOUSE_LEFT)) and ((player == nil) or (instance.vars.player == player)) then fire(instance) end end @@ -4838,16 +5051,8578 @@ function authorSwitchDown(what) if AUTHOR_GAME.verbs and (what == MOUSE_LEFT) and verbPress(1) then return end - local px, py = authorPointer(1) + local px, py = authorPointer(player or 1) - authorEvent("pressed", { switch = what, x = px, y = py }) + authorEvent("pressed", { switch = what, x = px, y = py, player = player }) end -function authorSwitchUp(what) +function authorSwitchUp(what, device) if authorSwitchIsKey(what) then return end AUTHOR_SWITCHES[what] = nil - authorEvent("released", { switch = what }) + authorEvent("released", { switch = what, player = device and (device + 1) or nil }) end + + +-- ===== The KarisFramework game loop =========================================================== +-- +-- The qte behaviour plays a laserdisc quick-time-event game the way the KarisFramework does: +-- 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, +-- and what plays in between. +-- +-- 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 +-- 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 +-- 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 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, + UPLEFT = 9, UPRIGHT = 10, DOWNLEFT = 11, DOWNRIGHT = 12, ACTUP = 13, ACTDOWN = 14, ACTLEFT = 15, ACTRIGHT = 16, + 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 + -- 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, + UL = 156, UR = 157, DL = 158, DR = 159, LU = 160, RU = 161, LD = 162, RD = 163, LUB = 164, RUB = 165, RDB = 166, LDB = 167, DLB2 = 168, DRB2 = 169, + RL = 170, LR = 171, UD = 172, DU = 173, ULB2 = 174, URB2 = 175, LUB2 = 176, RUB2 = 177, RDB2 = 178, LDB2 = 179, + DLU = 180, DRU = 181, LDR = 182, LUR = 183, RDL = 184, RUL = 185, ULD = 186, URD = 187, + MASHB2 = 188, MASHB2MAX = 189, MASHB2MIN = 190, MASHB3 = 192, MASHB3MAX = 193, MASHB3MIN = 194, + MASHLEFT = 196, MASHLEFTMIN = 197, MASHLEFTMAX = 198, MASHRIGHT = 200, MASHRIGHTMIN = 201, MASHRIGHTMAX = 202, + MASHUP = 204, MASHUPMIN = 205, MASHUPMAX = 206, MASHDOWN = 208, MASHDOWNMIN = 209, MASHDOWNMAX = 210, + LOOPRIGHTL = 212, LOOPRIGHTU = 213, LOOPRIGHTR = 214, LOOPRIGHTD = 215, LOOPLEFTL = 216, LOOPLEFTU = 217, LOOPLEFTR = 218, LOOPLEFTD = 219, + ACT2UP = 220, ACT2DOWN = 221, ACT2LEFT = 222, ACT2RIGHT = 223, ACT3UP = 224, ACT3DOWN = 225, ACT3LEFT = 226, ACT3RIGHT = 227, + ACTUPLEFT = 228, ACTUPRIGHT = 229, ACTDOWNLEFT = 230, ACTDOWNRIGHT = 231, ACT2UPLEFT = 232, ACT2UPRIGHT = 233, ACT2DOWNLEFT = 234, ACT2DOWNRIGHT = 235, + 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, + 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, + 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 +} + +-- The compound kinds as data: the inputs of a sequence in turn (a circle is five, back to its +-- start), the switch or pair a mash takes with its count per second of the window, and the +-- flags a combination wants all at once. +local MOVE_FLAG = { [QTE.UP] = "p1UP", [QTE.DOWN] = "p1DOWN", [QTE.LEFT] = "p1LEFT", [QTE.RIGHT] = "p1RIGHT", [QTE.BUTTON1] = "p1BUTTON1", [QTE.BUTTON2] = "p1BUTTON2", [QTE.BUTTON3] = "p1BUTTON3" } +local MAZE_SEQ = {} +local MAZE_MASH = {} +do + local U, D, L, R, B1, B2, B3 = QTE.UP, QTE.DOWN, QTE.LEFT, QTE.RIGHT, QTE.BUTTON1, QTE.BUTTON2, QTE.BUTTON3 + + MAZE_SEQ = { + [QTE.UL] = { U, L }, [QTE.UR] = { U, R }, [QTE.DL] = { D, L }, [QTE.DR] = { D, R }, [QTE.LU] = { L, U }, [QTE.RU] = { R, U }, [QTE.LD] = { L, D }, [QTE.RD] = { R, D }, + [QTE.RL] = { R, L }, [QTE.LR] = { L, R }, [QTE.UD] = { U, D }, [QTE.DU] = { D, U }, + [QTE.DLB] = { D, L, B1 }, [QTE.DRB] = { D, R, B1 }, [QTE.ULB] = { U, L, B1 }, [QTE.URB] = { U, R, B1 }, [QTE.LUB] = { L, U, B1 }, [QTE.RUB] = { R, U, B1 }, [QTE.RDB] = { R, D, B1 }, [QTE.LDB] = { L, D, B1 }, + [QTE.DLB2] = { D, L, B2 }, [QTE.DRB2] = { D, R, B2 }, [QTE.ULB2] = { U, L, B2 }, [QTE.URB2] = { U, R, B2 }, [QTE.LUB2] = { L, U, B2 }, [QTE.RUB2] = { R, U, B2 }, [QTE.RDB2] = { R, D, B2 }, [QTE.LDB2] = { L, D, B2 }, + [QTE.DLU] = { D, L, U }, [QTE.DRU] = { D, R, U }, [QTE.LDR] = { L, D, R }, [QTE.LUR] = { L, U, R }, [QTE.RDL] = { R, D, L }, [QTE.RUL] = { R, U, L }, [QTE.ULD] = { U, L, D }, [QTE.URD] = { U, R, D }, + [QTE.LOOPLEFTL] = { L, D, R, U, L }, [QTE.LOOPLEFTD] = { D, R, U, L, D }, [QTE.LOOPLEFTR] = { R, U, L, D, R }, [QTE.LOOPLEFTU] = { U, L, D, R, U }, + [QTE.LOOPRIGHTL] = { L, U, R, D, L }, [QTE.LOOPRIGHTU] = { U, R, D, L, U }, [QTE.LOOPRIGHTR] = { R, D, L, U, R }, [QTE.LOOPRIGHTD] = { D, L, U, R, D } + } + for _, mash in ipairs({ { QTE.MASHB2, QTE.MASHB2MIN, QTE.MASHB2MAX, 3, B2 }, { QTE.MASHB3, QTE.MASHB3MIN, QTE.MASHB3MAX, 3, B3 }, + { QTE.MASHLEFT, QTE.MASHLEFTMIN, QTE.MASHLEFTMAX, 6, L }, { QTE.MASHRIGHT, QTE.MASHRIGHTMIN, QTE.MASHRIGHTMAX, 6, R }, + { QTE.MASHUP, QTE.MASHUPMIN, QTE.MASHUPMAX, 6, U }, { QTE.MASHDOWN, QTE.MASHDOWNMIN, QTE.MASHDOWNMAX, 6, D }, + { QTE.MASH3, QTE.MASH3MIN, QTE.MASH3MAX, 6, B1, B3 } }) do + local spec = { min = mash[2], max = mash[3], times = mash[4], left = mash[5], right = mash[6] } + + MAZE_MASH[mash[1]], MAZE_MASH[mash[2]], MAZE_MASH[mash[3]] = spec, spec, spec + end +end +local ACT_WANTS = { [QTE.ACTUP] = { "p1BUTTON1", "p1UP" }, [QTE.ACTDOWN] = { "p1BUTTON1", "p1DOWN" }, [QTE.ACTLEFT] = { "p1BUTTON1", "p1LEFT" }, [QTE.ACTRIGHT] = { "p1BUTTON1", "p1RIGHT" }, + [QTE.UPLEFT] = { "p1UP", "p1LEFT" }, [QTE.UPRIGHT] = { "p1UP", "p1RIGHT" }, [QTE.DOWNLEFT] = { "p1DOWN", "p1LEFT" }, [QTE.DOWNRIGHT] = { "p1DOWN", "p1RIGHT" }, + [QTE.ACT2UP] = { "p1BUTTON2", "p1UP" }, [QTE.ACT2DOWN] = { "p1BUTTON2", "p1DOWN" }, [QTE.ACT2LEFT] = { "p1BUTTON2", "p1LEFT" }, [QTE.ACT2RIGHT] = { "p1BUTTON2", "p1RIGHT" }, + [QTE.ACT3UP] = { "p1BUTTON3", "p1UP" }, [QTE.ACT3DOWN] = { "p1BUTTON3", "p1DOWN" }, [QTE.ACT3LEFT] = { "p1BUTTON3", "p1LEFT" }, [QTE.ACT3RIGHT] = { "p1BUTTON3", "p1RIGHT" }, + [QTE.ACTUPLEFT] = { "p1BUTTON1", "p1UP", "p1LEFT" }, [QTE.ACTUPRIGHT] = { "p1BUTTON1", "p1UP", "p1RIGHT" }, [QTE.ACTDOWNLEFT] = { "p1BUTTON1", "p1DOWN", "p1LEFT" }, [QTE.ACTDOWNRIGHT] = { "p1BUTTON1", "p1DOWN", "p1RIGHT" }, + [QTE.ACT2UPLEFT] = { "p1BUTTON2", "p1UP", "p1LEFT" }, [QTE.ACT2UPRIGHT] = { "p1BUTTON2", "p1UP", "p1RIGHT" }, [QTE.ACT2DOWNLEFT] = { "p1BUTTON2", "p1DOWN", "p1LEFT" }, [QTE.ACT2DOWNRIGHT] = { "p1BUTTON2", "p1DOWN", "p1RIGHT" }, + [QTE.ACT3UPLEFT] = { "p1BUTTON3", "p1UP", "p1LEFT" }, [QTE.ACT3UPRIGHT] = { "p1BUTTON3", "p1UP", "p1RIGHT" }, [QTE.ACT3DOWNLEFT] = { "p1BUTTON3", "p1DOWN", "p1LEFT" }, [QTE.ACT3DOWNRIGHT] = { "p1BUTTON3", "p1DOWN", "p1RIGHT" } } +-- The framework's flag for each switch. +QTE.FLAG_OF = nil + + +local function qteFlagOf(switch) + if QTE.FLAG_OF == nil then + 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" } + end + + return QTE.FLAG_OF[switch] +end + + +-- ----- Timers, sounds, clips ---------------------------------------------------------------- + +function Q.timerON(q, seconds) + q.timerLimit = seconds + q.timerFrom = authorTime() +end + + +function Q.timerDue(q) + if q.timerLimit == nil then + return true + end + if authorTime() - q.timerFrom >= q.timerLimit then + q.timerLimit = nil + return true + end + + return false +end + + +function Q.joyDelayON(q, seconds) + q.joyLimit = seconds + q.joyFrom = authorTime() +end + + +function Q.joyDelayDue(q) + if q.joyLimit == nil then + return true + end + if authorTime() - q.joyFrom >= q.joyLimit then + q.joyLimit = nil + return true + end + + return false +end + + +function Q.setupClip(q, from, to) + q.iFrameStart = from + q.iFrameEnd = to + discSkipToFrame(from) +end + + +-- A framework sound by its handle name (sndcoin, sndright, ...), loaded from the game's Sounds +-- directory on first use. +function Q.sound(q, name) + local file = q.sounds[name] + + if file then + authorPlaySound(file, 100, q.instance) + 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. +function Q.secretPressed(q) + if not q.AllowSecret then + return false + end + if q.snapshot.framework == "3.32b" then + return q.p1BUTTON4 and q.p1UP + 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 +end + + +function Q.scanInput(q) + if q.p1BUTTON1 then return QTE.BUTTON1 end + if q.p1BUTTON2 then return QTE.BUTTON2 end + if q.p1BUTTON3 then return QTE.BUTTON3 end + if q.p1UP then return QTE.UP end + if q.p1DOWN then return QTE.DOWN end + if q.p1LEFT then return QTE.LEFT end + if q.p1RIGHT then return QTE.RIGHT end + + return QTE.NOMOVE +end + + +function Q.clearInput(q) + q.p1UP, q.p1DOWN, q.p1LEFT, q.p1RIGHT, q.p1BUTTON1, q.p1BUTTON2, q.p1BUTTON3 = false, false, false, false, false, false, false +end + + +-- ----- Scoring and what was beaten ---------------------------------------------------------- + +function Q.addPoints(q, thisMuch, thisValue) + q.thisScore = 0 + if q.specialScore then + q.specialScore(thisValue) + 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 + 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 + q.iRightMv = 0 + q.iLifeBar = q.iLifeBar + 1 + 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 + + +function Q.beatLevel(q, thisLevel) + local k = q.Level[thisLevel][QTE.TOTALSCENES] + + return q.scene[thisLevel][k][QTE.SCENECOMPLETE] +end + + +function Q.beatGame(q) + for k = 1, q.finalstage do + if not q.stage[k][QTE.BEATSTATUS] then + return false + end + end + + return true +end + + +function Q.beatGameWithOneLife(q) + if not Q.beatGame(q) then + return false + end + for k = 1, q.finalstage do + if q.stage[k][QTE.DEATHCOUNT] > 0 then + return false + end + end + + return true +end + + +function Q.beatGameWithOneCredit(q) + return Q.beatGame(q) and (q.iContinues == 0) +end + + +function Q.newScore(q, score) + for k = 1, 10 do + local entry = q.highScores[k] + + if entry and (score >= entry[2]) then + return true + end + end + + return false +end + + +-- ----- Levels: their order, their scenes, their frames -------------------------------------- + +function Q.initStages(q) + q.stage = {} + q.scene = {} + for k = 1, q.finalstage do + q.scene[k] = {} + q.stage[k] = { false, false, 0 } + q.LvlOrder[k] = k + for i = 1, q.Level[k][QTE.TOTALSCENES] do + q.scene[k][i] = { i, false } + 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 +end + + +function Q.doMixSEQ(q) + q.LvlOrder = {} + for i = 1, q.finalstage do + q.LvlOrder[i] = q.PlayOrder[i] + end +end + + +function Q.doMixTIE(q) + local count = 1 + + q.LvlOrder = {} + q.LvlOrder[q.finalstage] = q.finalstage + for i = 1, q.Tiers[0][1] do + local size = q.Tiers[0][i + 1] + local tier = q.Tiers[i] + local took = 1 + + while took <= size do + local pick = math.random(size) + local placed = false + + for k = 1, q.finalstage do + if q.LvlOrder[k] == tier[pick] then + placed = true + break + end + end + if not placed then + q.LvlOrder[count] = tier[pick] + count = count + 1 + took = took + 1 + end + end + end +end + + +function Q.doMixRND(q) + q.LvlOrder = {} + q.LvlOrder[q.finalstage] = q.finalstage + for w = 1, q.finalstage - 1 do + while true do + local pick = math.random(q.finalstage - 1) + local placed = false + + for k = 1, q.finalstage - 1 do + if q.LvlOrder[k] == pick then + placed = true + break + end + end + if not placed then + q.LvlOrder[w] = pick + break + end + end + end +end + + +function Q.nextLevel(q, thisLevel) + q.iScPlayed, q.iScDeath, q.iTotDeath = 0, 0, 0 + 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 + else + 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 + + +function Q.reOrder(q, thisLevel) + local replay = q.Level[thisLevel][QTE.LVLREPLAY] + local n = 0 + + for k = 1, q.finalstage - 1 do + if q.LvlOrder[k] == thisLevel then + n = q.LvlOrder[k + 1] + for i = k, replay - 1 do + q.LvlOrder[i] = q.LvlOrder[i + 1] + end + q.LvlOrder[replay] = thisLevel + break + end + end + q.iLevel = n + q.iScene = 0 + if q.swapLevel then + q.swapLevel() + end +end + + +function Q.onToNextLevel(q) + q.bSkipIntroClip = false + q.iLiveSave = q.iLives + q.iScoreSave = q.iScore + q.bAllowSave = true + q.bRes = true + q.iScoreTemp = 0 + q.iBonus = 0 + if q.dip_PlayStyle == 3 then + q.lvlState = QTE.lvlSetup + q.currentLevel = QTE.levelMap + else + Q.nextLevel(q, q.iLevel) + q.lvlState = QTE.lvlSetup + q.currentLevel = QTE.levelNormal + end +end + + +function Q.levelReplay(q) + local replay = q.Level[q.iLevel][QTE.LVLREPLAY] + + if replay == 0 then + Q.onToNextLevel(q) + elseif replay == 1 then + if not q.stage[q.iLevel][QTE.LEVELSTARTED] then + q.stage[q.iLevel][QTE.LEVELSTARTED] = true + else + Q.onToNextLevel(q) + 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 + 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 + else + if q.dip_PlayStyle == 3 then + q.currentLevel = QTE.levelMap + else + Q.nextLevel(q, q.iLevel) + q.currentLevel = QTE.levelNormal + end + end + q.lvlState = QTE.lvlSetup + end +end + + +-- The framework's frame arithmetic on a scene's moves. +function Q.setupFrames(q, thisLevel) + local level = q.Level[thisLevel] + local w = q.RelativeFrames and 1 or 0 + local flipBy = q.bFlip and level[QTE.MIRROR] or 0 + local move = q.move + + q.sceneStart = q.sceneStart + w * level[QTE.INTROCLIP] + flipBy + q.sceneEnd = q.sceneEnd + w * level[QTE.INTROCLIP] + flipBy + q.Tlimit = q.sceneStart + 50 + for k = 1, q.totalMoves do + move[k][1] = move[k][1] + w * level[QTE.INTROCLIP] + flipBy + q.iPenal + move[k][2] = move[k][2] + w * level[QTE.INTROCLIP] + flipBy + end + for k = 1, q.totalMoves do + local m = move[k] + + if q.dip_GameType == 4 then + if not ((m[3] >= QTE.CHOOSE) and (m[3] <= QTE.YESNO)) then + m[3] = QTE.BUTTON1 + end + else + if m[3] == q.DOUBLE then + m[3], m[5], m[6] = QTE.MULTI, QTE.BUTTON1, 2 + end + if q.dip_Difficulty == 0 then + local easy = { [QTE.ACTUP] = QTE.UP, [QTE.ACTDOWN] = QTE.DOWN, [QTE.ACTLEFT] = QTE.LEFT, [QTE.ACTRIGHT] = QTE.RIGHT, + [QTE.HOLDUP] = QTE.UP, [QTE.HOLDDOWN] = QTE.DOWN, [QTE.HOLDLEFT] = QTE.LEFT, [QTE.HOLDRIGHT] = QTE.RIGHT, + [QTE.MASH] = QTE.BUTTON1, [QTE.MASHMIN] = QTE.BUTTON1, [QTE.MASHMAX] = QTE.BUTTON1, [QTE.LETGO] = QTE.BUTTON1, + [QTE.RUN] = QTE.BUTTON1, [QTE.RUNMIN] = QTE.BUTTON1, [QTE.RUNMAX] = QTE.BUTTON1, + [QTE.LOOPLEFT] = QTE.LEFT, [QTE.LOOPRIGHT] = QTE.RIGHT, + [QTE.ACT2UP] = QTE.UP, [QTE.ACT3UP] = QTE.UP, [QTE.ACT2DOWN] = QTE.DOWN, [QTE.ACT3DOWN] = QTE.DOWN, + [QTE.ACT2LEFT] = QTE.LEFT, [QTE.ACT3LEFT] = QTE.LEFT, [QTE.ACT2RIGHT] = QTE.RIGHT, [QTE.ACT3RIGHT] = QTE.RIGHT, + [QTE.LOOPLEFTL] = QTE.LEFT, [QTE.LOOPLEFTD] = QTE.LEFT, [QTE.LOOPLEFTR] = QTE.LEFT, [QTE.LOOPLEFTU] = QTE.LEFT, + [QTE.LOOPRIGHTL] = QTE.RIGHT, [QTE.LOOPRIGHTD] = QTE.RIGHT, [QTE.LOOPRIGHTR] = QTE.RIGHT, [QTE.LOOPRIGHTU] = QTE.RIGHT } + + if m[3] == QTE.MULTI then + m[3] = m[5] + elseif easy[m[3]] then + m[3] = easy[m[3]] + end + else + if q.dip_MashtoRun == 2 then + if m[3] == QTE.RUN then m[3] = QTE.MASH2 elseif m[3] == QTE.RUNMIN then m[3] = QTE.MASH2MIN elseif m[3] == QTE.RUNMAX then m[3] = QTE.MASH2MAX end + elseif q.dip_MashtoRun == 3 then + if (m[3] == QTE.RUN) or (m[3] == QTE.MASH2) then m[3] = QTE.MASH + elseif (m[3] == QTE.RUNMIN) or (m[3] == QTE.MASH2MIN) then m[3] = QTE.MASHMIN + elseif (m[3] == QTE.RUNMAX) or (m[3] == QTE.MASH2MAX) then m[3] = QTE.MASHMAX end + end + if q.dip_HoldtoLoop == 1 then + if m[3] == QTE.LOOPLEFT then m[3] = QTE.HOLDLEFT elseif m[3] == QTE.LOOPRIGHT then m[3] = QTE.HOLDRIGHT end + end + end + end + if q.bFlip then + local mirrored = { [QTE.LEFT] = QTE.RIGHT, [QTE.RIGHT] = QTE.LEFT, [QTE.ACTLEFT] = QTE.ACTRIGHT, [QTE.ACTRIGHT] = QTE.ACTLEFT, + [QTE.UPLEFT] = QTE.UPRIGHT, [QTE.UPRIGHT] = QTE.UPLEFT, [QTE.DOWNLEFT] = QTE.DOWNRIGHT, [QTE.DOWNRIGHT] = QTE.DOWNLEFT, + [QTE.HOLDLEFT] = QTE.HOLDRIGHT, [QTE.HOLDRIGHT] = QTE.HOLDLEFT, [QTE.LOOPLEFT] = QTE.LOOPRIGHT, [QTE.LOOPRIGHT] = QTE.LOOPLEFT } + local function swapSide(value) + if value == QTE.LEFT then return QTE.RIGHT elseif value == QTE.RIGHT then return QTE.LEFT end + return value + end + + if mirrored[m[3]] then + m[3] = mirrored[m[3]] + elseif (m[3] == QTE.PATH) and q.path[k] then + for _, slot in ipairs({ 1, 3, 5, 7 }) do + q.path[k][slot] = swapSide(q.path[k][slot]) + end + elseif m[3] == QTE.TIMED then + local from, to = m[5] or k, m[6] or k + + for p = from, to do + if q.timed[p] then + q.timed[p][1] = swapSide(q.timed[p][1]) + q.timed[p][2] = q.timed[p][2] + flipBy + q.timed[p][3] = q.timed[p][3] + flipBy + end + end + elseif m[3] == QTE.MULTI then + m[5] = swapSide(m[5]) + end + end + local before = move[k - 1] + local first = (k == 1) or (m[3] == QTE.WAY) or (m[3] == QTE.WAYOUT) + + if first then + m[7] = m[1] + elseif ((before[3] == QTE.WAY) or (m[3] == QTE.WAYOUT)) and (before[8] > m[1]) then + m[7] = move[k - 2][2] + 1 + else + m[7] = before[2] + 1 + end + m[8] = (k < q.totalMoves) and m[2] or q.sceneEnd + end +end + + +-- The scene's moves: from the game's own setupMoves when its script is loaded, else from the +-- description's snapshot of what setupMoves declared. +function Q.loadMoves(q, thisLevel, thisScene) + q.move, q.path, q.choice, q.timed = {}, {}, {}, {} + q.sceneStart, q.sceneEnd, q.totalMoves = 0, 0, 0 + if q.setupMoves then + q.setupMoves(thisLevel, thisScene) + return + end + local level = (thisLevel == QTE.levelSecret) and q.snapshot.levels.secret or q.snapshot.levels[thisLevel] + local play = (level.playBy and level.playBy[q.dip_Difficulty] or level.play)[thisScene] + + q.sceneStart = play.start + q.sceneEnd = play.finish + q.totalMoves = #play.moves + for index, m in ipairs(play.moves) do + q.move[index] = { m[1], m[2], m[3], m[4], m[5], m[6] } + end + for index, c in pairs(play.choices or {}) do + q.choice[index] = { c[1], c[2], c[3] } + end + for index, p in pairs(play.paths or {}) do + local copy = {} + + for slot = 1, 9 do + copy[slot] = p[slot] + end + q.path[index] = copy + end + for index, t in pairs(play.timed or {}) do + q.timed[index] = { t[1], t[2], t[3], t[4], t[5] } + end +end + + +function Q.setupLevel(q, thisLevel) + local level = q.Level[thisLevel] + + q.iScene = q.iScene + 1 + q.iPath = 0 + q.iPathAjmp = 0 + q.iPathAend = 0 + if q.swapScene then + q.swapScene() + 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.loadMoves(q, thisLevel, q.iScene) + Q.setupFrames(q, thisLevel) + if q.bAllowSave and (q.dip_GameType ~= 2) and (q.dip_GameType ~= 3) then + q.bAllowSave = false + end +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] + + 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.lvlState = QTE.lvlPlayRest + return + end + q.lvlState = QTE.lvlPlayDeath + q.iLives = q.iLives - 1 + if curDeath == 0 then + curDeath = math.random(q.totalDeath) + end + local clip = q.Death[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 + 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 + 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 + 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 + + +-- ----- The tests of the harder moves --------------------------------------------------------- + +function Q.checkHold(q, playerMove, curMove) + local z = Q.scanInput(q) + local m = q.move[q.currentMove] + + if (q.currentFrame == m[QTE.inputFrmStart]) and (z == playerMove) then + z = QTE.MOVEFAIL + elseif z == playerMove then + if q.iLenHold >= q.lenCounter then + z = curMove + q.iLenHold = 0 + else + z = QTE.MOVEPENDING + if q.bTestHold and (q.currentFrame == q.lastHold) then + q.lastHold = q.currentFrame + elseif q.bTestHold and (q.currentFrame == q.lastHold + 1) then + q.iLenHold = q.iLenHold + 1 + q.lastHold = q.currentFrame + else + q.lastHold = q.currentFrame + end + end + else + if z ~= QTE.NOMOVE then + q.iLenHold = 0 + z = QTE.MOVEFAIL + else + if q.iLenHold > 0 then + q.iLenHold = q.iLenHold - 1 + end + z = QTE.MOVEPENDING + end + end + + return z +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 + return curMove + end + + return QTE.MOVEPENDING +end + + +function Q.checkLoop(q, playerMove, curMove, first, third) + local z = QTE.MOVEPENDING + local firstFlag = (first == QTE.LEFT) and "p1LEFT" or "p1RIGHT" + local thirdFlag = (third == QTE.LEFT) and "p1LEFT" or "p1RIGHT" + + if (playerMove == first) and (q.iLoopStep == 0) then + q.iLoopPrev, q.iLoopStep, q[firstFlag] = 0, 1, false + elseif (playerMove == QTE.DOWN) and (q.iLoopPrev == 0) and (q.iLoopStep == 1) then + q.iLoopPrev, q.iLoopStep, q.p1DOWN = 1, 2, false + elseif (playerMove == third) and (q.iLoopPrev == 1) and (q.iLoopStep == 2) then + q.iLoopPrev, q.iLoopStep, q[thirdFlag] = 2, 3, false + elseif (playerMove == QTE.UP) and (q.iLoopPrev == 2) and (q.iLoopStep == 3) then + q.iLoopPrev, q.iLoopStep, q.p1UP = 3, 4, false + elseif (playerMove == first) and (q.iLoopPrev == 3) and (q.iLoopStep == 4) then + z = curMove + q.iLoopPrev, q.iLoopStep, q[firstFlag] = 0, 0, false + end + + return z +end + + +function Q.checkMash(q, playerMove, curMove) + local z = QTE.MOVEPENDING + + if playerMove == QTE.BUTTON1 then + q.bTestMash = false + if q.iMash >= q.mashCounter then + z = curMove + q.p1BUTTON1 = false + end + else + if q.iMash > 0 then + q.iMash = q.iMash - q.unMash + end + if playerMove ~= QTE.NOMOVE then + q.iMash = 0 + z = QTE.MOVEFAIL + end + end + + return z +end + + +function Q.checkMash2(q, playerMove, curMove) + local z = QTE.MOVEPENDING + + if (playerMove == QTE.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 == QTE.BUTTON2) and q.bTestMashR then + q.bTestMashR, q.bTestMashL = false, true + if q.iMash >= q.mashCounter then + z = curMove + q.p1BUTTON2 = false + end + else + if q.iMash > 0 then + q.iMash = q.iMash - q.unMash + end + if playerMove ~= QTE.NOMOVE then + q.iMash = 0 + z = QTE.MOVEFAIL + end + end + + return z +end + + +-- A sequence of inputs, each on its own frame: the framework's checkul, checkdlb, +-- checkLOOPLEFTU, and their kin, one step at a time through iLoopStep. A circle (five steps) +-- starts over when done; a shorter sequence leaves its count for the next move to clear. +function Q.checkSequence(q, playerMove, curMove, steps) + local z = QTE.MOVEPENDING + local next = q.iLoopStep + 1 + + if (playerMove == steps[next]) and ((next == 1) or (q.iLoopPrev == next - 2)) then + q.iLoopPrev, q.iLoopStep = next - 1, next + q[MOVE_FLAG[playerMove]] = false + if next == #steps then + z = curMove + if #steps == 5 then + q.iLoopPrev, q.iLoopStep = 0, 0 + end + end + end + + return z +end + + +-- A mash of one switch (the framework's checkMashleft and its kin) or of two in turn (its +-- checkMash3): the left flag takes the first, the right flag the second, and any other input +-- ends it. +function Q.checkMashOf(q, playerMove, curMove, spec) + local z = QTE.MOVEPENDING + + if (playerMove == spec.left) and q.bTestMashL then + q.bTestMashL, q.bTestMashR = false, true + if q.iMash >= q.mashCounter then + z = curMove + end + elseif (spec.right ~= nil) and (playerMove == spec.right) and q.bTestMashR then + q.bTestMashR, q.bTestMashL = false, true + if q.iMash >= q.mashCounter then + z = curMove + q[MOVE_FLAG[playerMove]] = false + end + else + if q.iMash > 0 then + q.iMash = q.iMash - q.unMash + end + if playerMove ~= QTE.NOMOVE then + q.iMash = 0 + z = QTE.MOVEFAIL + end + end + + return z +end + + +function Q.checkMulti(q, playerMove, curMove) + local m = q.move[q.currentMove] + local z = QTE.MOVEPENDING + + if playerMove == m[5] then + q.bTestMulti = false + if q.iMulti >= m[6] then + z = curMove + end + elseif playerMove ~= QTE.NOMOVE then + z = QTE.MOVEFAIL + end + + return z +end + + +function Q.checkRun(q, playerMove, curMove) + local z = QTE.MOVEPENDING + + if playerMove == QTE.LEFT then + q.bTestRunL, q.bTestRunR, q.p1LEFT = false, true, false + if q.iMash >= q.mashCounter then + z = curMove + end + elseif playerMove == QTE.RIGHT then + q.bTestRunR, q.bTestRunL, q.p1RIGHT = false, true, false + if q.iMash >= q.mashCounter then + z = curMove + end + elseif playerMove == QTE.UP then + q.p1UP = false + elseif playerMove == QTE.DOWN then + q.p1DOWN = false + else + if q.iMash > 0 then + q.iMash = q.iMash - q.unMash + end + if playerMove ~= QTE.NOMOVE then + q.iMash = 0 + z = QTE.MOVEFAIL + end + end + + return z +end + + +function Q.checkSkip(q, playerMove, curMove) + if playerMove ~= QTE.NOMOVE then + Q.clearInput(q) + q.bShowAction = false + discSkipToFrame(q.move[q.currentMove][QTE.inputFrmEnd]) + end + + return curMove +end + + +-- ----- The screens --------------------------------------------------------------------------- + +function Q.startGame(q) + math.randomseed(KARIS_SEED or os.time()) + math.random(100) + if q.bExtendedPlay then + Q.initStages(q) + q.currentLevel = QTE.levelNormal + q.iLevel = QTE.levelSecret + else + if q.iCredits > 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 + 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) + 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 + 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 + 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 + q.iRightMv, q.iWrongMv = 0, 0 + q.iTop = (q.dip_GameType == 1) and q.iTopLB or q.iTopN + 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 + 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 + elseif q.dip_GameType == 3 then + Q.doMixSEQ(q) + q.iLevel, q.iScene = 1, 0 + q.iTop = q.iTopS + q.currentLevel = QTE.levelNormal + end + if q.startConf then + q.startConf() + end + end + end + if q.IngameDiffchoice and (q.dip_Diffshow == 4) and q.bOneDiff then + q.altState = QTE.lvlSetup + q.currentLevel = QTE.levelDiffScreen + 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.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 +end + + +function Q.doIntro(q) + if q.lvlState == QTE.lvlSetup then + Q.setupClip(q, q.offsetIntro01, q.offsetIntro01end) + q.lvlState = QTE.branch01 + q.bShowCredits = true + q.bShowLCD = true + q.bShowLives = false + elseif q.lvlState == QTE.branch01 then + if (q.currentFrame >= q.iFrameEnd) or q.p1BUTTON1 then + q.p1BUTTON1 = false + discSkipToFrame(q.frameControls) + discPause() + q.bShowLCD, q.bShowCredits = false, false + Q.timerON(q, 10) + q.lvlState = (q.frameSpecial ~= q.frameControls) and QTE.branch02 or QTE.branch03 + elseif Q.secretPressed(q) then + q.p1BUTTON2, q.p1BUTTON3, q.p1BUTTON4, q.p1UP, q.p1RIGHT = false, false, false, false, false + q.bExtendedPlay = true + Q.startGame(q) + q.bShowCredits = false + end + elseif q.lvlState == QTE.branch02 then + if Q.timerDue(q) or q.p1BUTTON1 then + q.p1BUTTON1 = false + discSkipToFrame(q.frameSpecial) + discPause() + q.bShowLCD, q.bShowCredits = false, false + Q.timerON(q, 10) + q.lvlState = QTE.branch03 + end + elseif q.lvlState == QTE.branch03 then + if Q.timerDue(q) or q.p1BUTTON1 then + q.p1BUTTON1 = false + q.bShowLCD, q.bShowCredits = true, true + Q.setupClip(q, q.offsetIntro02, q.offsetIntro02end) + q.lvlState = QTE.branch04 + end + elseif q.lvlState == QTE.branch04 then + if (q.currentFrame >= q.iFrameEnd) or q.p1BUTTON1 then + q.p1BUTTON1 = false + q.bShowLCD, q.bShowCredits = false, false + discSkipToFrame(q.frameRankings) + Q.timerON(q, 10) + discPause() + q.lvlState = QTE.branch05 + elseif Q.secretPressed(q) then + q.p1BUTTON2, q.p1BUTTON3, q.p1BUTTON4, q.p1UP, q.p1RIGHT = false, false, false, false, false + q.bExtendedPlay = true + Q.startGame(q) + q.bShowCredits = false + end + elseif q.lvlState == QTE.branch05 then + if Q.timerDue(q) or q.p1BUTTON1 then + q.p1BUTTON1 = false + q.bShowLCD, q.bShowCredits = true, true + Q.setupClip(q, q.offsetIntro03, q.offsetIntro03end) + q.lvlState = (q.LvlTrophy3 ~= 0) and QTE.branch06 or QTE.branch09 + elseif Q.secretPressed(q) then + q.p1BUTTON2, q.p1BUTTON3, q.p1BUTTON4, q.p1UP, q.p1RIGHT = false, false, false, false, false + q.bExtendedPlay = true + Q.startGame(q) + q.bShowCredits = false + end + elseif q.lvlState == QTE.branch06 then + if (q.currentFrame >= q.iFrameEnd) or q.p1BUTTON1 then + q.p1BUTTON1 = false + q.bShowLCD, q.bShowCredits = false, false + discSkipToFrame(q.frameTrophy) + Q.timerON(q, 8) + discPause() + q.lvlState = QTE.branch07 + end + elseif q.lvlState == QTE.branch07 then + if Q.timerDue(q) or q.p1BUTTON1 then + q.p1BUTTON1 = false + discSkipToFrame(q.frameTrophy) + Q.timerON(q, 8) + discPause() + q.lvlState = QTE.branch08 + end + elseif q.lvlState == QTE.branch08 then + if Q.timerDue(q) or q.p1BUTTON1 then + q.p1BUTTON1 = false + q.bShowLCD, q.bShowCredits = true, true + Q.setupClip(q, q.offsetTitle, q.offsetTitleend) + q.lvlState = QTE.branch09 + end + elseif q.lvlState == QTE.branch09 then + if (q.currentFrame >= q.iFrameEnd) or q.p1BUTTON1 then + q.p1BUTTON1 = false + q.bShowLCD, q.bShowCredits = false, false + discSkipToFrame(q.frameRankings) + Q.timerON(q, 8) + discPause() + q.lvlState = QTE.branch10 + elseif Q.secretPressed(q) then + q.p1BUTTON2, q.p1BUTTON3, q.p1BUTTON4, q.p1UP, q.p1RIGHT = false, false, false, false, false + q.bExtendedPlay = true + Q.startGame(q) + q.bShowCredits = false + end + elseif q.lvlState == QTE.branch10 then + if Q.timerDue(q) or q.p1BUTTON1 then + q.p1BUTTON1 = false + q.bShowLCD, q.bShowCredits = false, false + q.lvlState = QTE.lvlSetup + end + end + if (q.dip_CoinsPerCredit == QTE.DOPT_FREEPLAY) or (q.bShowCredits and (q.iCredits > 0)) then + if q.p1START1 and (q.dip_GameType ~= 5) then + q.p1START1 = false + q.bShowCredits = false + q.i2P = 0 + Q.startGame(q) + end + end +end + + +function Q.doChoose(q) + local m = q.move[q.currentMove] + local addPoints = q.addPoints or Q.addPoints -- Kimmy scores its own way. + + q.numChoice = m[QTE.moveDeath] + if q.altState == QTE.lvlSetup then + q.altState = QTE.lvlRunning + q.iChoice = 1 + q.bIgnoreJoy = false + q.bShowChoices = true + -- The framework shuffles the options on their first drawing, from the one random stream. + if q.bShuffleOrder then + q.bShuffleOrder = false + if q.numChoice == 2 then + q.optorder = ({ { 1, 2 }, { 2, 1 } })[math.random(2)] + elseif q.numChoice == 3 then + q.optorder = ({ { 1, 2, 3 }, { 2, 3, 1 }, { 3, 1, 2 }, { 1, 3, 2 }, { 2, 1, 3 }, { 3, 2, 1 } })[math.random(6)] + elseif q.numChoice == 4 then + q.optorder = ({ { 1, 2, 3, 4 }, { 4, 2, 3, 1 }, { 3, 4, 1, 2 }, { 1, 3, 2, 4 }, { 2, 1, 4, 3 }, { 4, 3, 2, 1 } })[math.random(6)] + end + end + elseif q.altState == QTE.lvlRunning then + local worth = q.SCOREMOVE + q.dip_Difficulty * q.BUFFMOVE + + if q.currentFrame > m[QTE.inputFrmEnd] then + if q.choice[q.optorder[q.iChoice]][2] == true then + Q.sound(q, "sndright") + q.iRightMv = q.iRightMv + 1 + addPoints(q, worth, q.currentMove) + q.iScoreTemp = q.iScoreTemp + worth + q.lvlState = QTE.lvlPlayRest + else + m[QTE.moveDeath] = q.choice[q.optorder[q.iChoice]][3] + q.bShowAction, q.bShowNext = false, false + q.lvlState = QTE.branch02 + end + q.bShowChoices = false + elseif (q.currentFrame >= m[QTE.inputFrmStart]) and (q.currentFrame <= m[QTE.inputFrmEnd]) then + local thisMove = QTE.NOMOVE + + if q.bIgnoreJoy then + if Q.timerDue(q) then + q.bIgnoreJoy = false + end + else + thisMove = Q.scanInput(q) + end + if thisMove == QTE.UP then + q.p1UP = false + if q.iChoice > 1 then + q.iChoice = q.iChoice - 1 + Q.sound(q, "sndcoin") + end + elseif thisMove == QTE.DOWN then + q.p1DOWN = false + if q.iChoice < q.numChoice then + q.iChoice = q.iChoice + 1 + Q.sound(q, "sndcoin") + end + elseif thisMove == QTE.BUTTON1 then + q.p1BUTTON1 = false + if q.choice[q.optorder[q.iChoice]][2] == true then + Q.sound(q, "sndright") + q.iRightMv = q.iRightMv + 1 + addPoints(q, worth, q.currentMove) + q.iScoreTemp = q.iScoreTemp + worth + discSkipToFrame(m[QTE.inputFrmEnd]) + q.lvlState = QTE.lvlPlayRest + else + m[QTE.moveDeath] = q.choice[q.optorder[q.iChoice]][3] + q.bShowAction, q.bShowNext = false, false + if (q.dip_GameType == 2) or (q.dip_GameType == 1) then + discSkipToFrame(m[QTE.inputFrmEnd]) + end + q.lvlState = QTE.branch02 + end + q.bShowChoices = false + end + end + 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 Q.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 + Q.addPoints(q, bonus, 0) + end + Q.addPoints(q, q.SCOREGAME, 0) + if Q.beatGameWithOneLife(q) then + Q.addPoints(q, q.SCORESECRET, 0) + end + else + if bonus > 0 then + Q.addPoints(q, bonus, 0) + end + Q.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 + Q.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 + + +-- After a death clip: the death counted, the rewind setting says where play goes. +function Q.afterDeath(q, thisLevel) + local m = q.move[q.currentMove] + local after = q.move[q.currentMove + 1] + + 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 ((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 + 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 + Q.setupClip(q, q.offsetGetReady, q.offsetGetReadyEnd) + q.bShowGet, q.bShowTop = true, false + 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 + end +end + + +function Q.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 + + +function Q.wrong(q) + q.iPauseFrame = q.move[q.currentMove][QTE.inputFrmEnd] + q.bShowAction = false + q.lvlState = QTE.branch02 +end + + +function Q.good(q) + q.lvlState = QTE.branch04 +end + + +-- The move waited for, judged by its kind: the framework's tests, a branch per kind. +function Q.judge(q) + local m = q.move[q.currentMove] + local kind = m[QTE.correctMove] + local thisMove = QTE.NOMOVE + local fps = q.MovieFPS + + if (kind >= QTE.HOLDUP) and (kind <= QTE.HOLDBUT) then + q.bTestHold = true + if q.bCalc then + q.bCalc = false + q.lenCounter = (m[QTE.inputFrmEnd] - m[QTE.inputFrmStart]) - (14 - q.dip_Difficulty) + end + thisMove = Q.checkHold(q, kind - 20, kind) + if thisMove == kind then + q.bTestHold, q.iLenHold, q.lastHold, q.bCalc = false, 0, 0, true + Q.good(q) + elseif thisMove ~= QTE.MOVEPENDING then + Q.wrong(q) + end + elseif kind == QTE.LETGO then + thisMove = Q.checkLet(q, Q.scanInput(q), kind) + if thisMove == kind then + Q.good(q) + elseif thisMove ~= QTE.MOVEPENDING then + Q.wrong(q) + end + elseif (kind == QTE.LOOPLEFT) or (kind == QTE.LOOPRIGHT) then + local first = (kind == QTE.LOOPLEFT) and QTE.LEFT or QTE.RIGHT + local third = (kind == QTE.LOOPLEFT) and QTE.RIGHT or QTE.LEFT + + thisMove = Q.checkLoop(q, Q.scanInput(q), kind, first, third) + if thisMove == kind then + Q.good(q) + elseif thisMove ~= QTE.MOVEPENDING then + Q.wrong(q) + end + elseif (kind >= QTE.RUN) and (kind <= QTE.RUNMAX) then + thisMove = Q.scanInput(q) + q.bTestRunL, q.bTestRunR = true, true + 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.dip_MashtoRun == 1) and 6 or 9) + if kind == QTE.RUNMIN then q.unMash = q.unMash - 0.01 elseif kind == QTE.RUNMAX then q.unMash = q.unMash + 0.01 end + end + thisMove = Q.checkRun(q, thisMove, kind) + if thisMove == kind then + q.bTestRunL, q.bTestRunR, q.iMash, q.bCalc = false, false, 0, true + Q.clearInput(q) + Q.good(q) + elseif thisMove ~= QTE.MOVEPENDING then + Q.wrong(q) + end + elseif (kind >= QTE.MASH2) and (kind <= QTE.MASH2MAX) then + thisMove = Q.scanInput(q) + q.bTestMashL, q.bTestMashR = true, true + 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) + 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) + if thisMove == kind then + q.bTestMashL, q.bTestMashR, q.iMash, q.bCalc = false, false, 0, true + Q.good(q) + elseif thisMove ~= QTE.MOVEPENDING then + Q.wrong(q) + end + elseif (kind >= QTE.MASH) and (kind <= QTE.MASHMAX) then + thisMove = Q.scanInput(q) + q.bTestMash = true + if q.bCalc then + q.bCalc = false + q.unMash = 0.07 + q.dip_Difficulty / 100 + q.mashCounter = ((m[QTE.inputFrmEnd] - m[QTE.inputFrmStart]) / fps) * 2.8 + if kind == QTE.MASHMIN then q.unMash = q.unMash - 0.01 elseif kind == QTE.MASHMAX then q.unMash = q.unMash + 0.01 end + end + thisMove = Q.checkMash(q, thisMove, kind) + if thisMove == kind then + q.bTestMash, q.iMash, q.bCalc = false, 0, true + Q.good(q) + elseif thisMove ~= QTE.MOVEPENDING then + Q.wrong(q) + end + elseif kind == QTE.MULTI then + q.bTestMulti = true + thisMove = Q.checkMulti(q, Q.scanInput(q), kind) + if thisMove == kind then + q.bTestMulti = false + Q.good(q) + elseif thisMove ~= QTE.MOVEPENDING then + Q.wrong(q) + end + elseif MAZE_SEQ[kind] then + thisMove = Q.checkSequence(q, Q.scanInput(q), kind, MAZE_SEQ[kind]) + if thisMove == kind then + Q.good(q) + elseif thisMove ~= QTE.MOVEPENDING then + Q.wrong(q) + end + elseif MAZE_MASH[kind] then + local spec = MAZE_MASH[kind] + + thisMove = Q.scanInput(q) + q.bTestMashL, q.bTestMashR = true, true + if q.bCalc then + q.bCalc = false + q.unMash = 0.07 + q.dip_Difficulty / 100 + q.mashCounter = ((m[QTE.inputFrmEnd] - m[QTE.inputFrmStart]) / fps) * spec.times + if kind == spec.min then q.unMash = q.unMash - 0.01 elseif kind == spec.max then q.unMash = q.unMash + 0.01 end + end + thisMove = Q.checkMashOf(q, thisMove, kind, spec) + if thisMove == kind then + q.bTestMashL, q.bTestMashR, q.iMash, q.bCalc = false, false, 0, true + Q.good(q) + elseif thisMove ~= QTE.MOVEPENDING then + Q.wrong(q) + end + elseif (kind <= QTE.RIGHT) or ((kind >= QTE.BUTTON1) and (kind <= QTE.BUTTON4)) then + thisMove = Q.scanInput(q) + if thisMove ~= QTE.NOMOVE then + if (thisMove == kind) or ((m[5] ~= nil) and (thisMove == m[5])) then + Q.good(q) + else + Q.wrong(q) + end + end + elseif ACT_WANTS[kind] then + -- Inputs together: every one the kind wants down at once, and none it does not. + local wants = ACT_WANTS[kind] + local other = false + local all = true + + for _, name in ipairs({ "p1UP", "p1DOWN", "p1LEFT", "p1RIGHT", "p1BUTTON1", "p1BUTTON2", "p1BUTTON3" }) do + local wanted = false + + for _, want in ipairs(wants) do + if want == name then + wanted = true + end + end + if q[name] and not wanted then + other = true + end + if wanted and not q[name] then + all = false + end + end + if (q.currentFrame >= m[QTE.inputFrmEnd]) or other then + Q.wrong(q) + elseif all then + Q.good(q) + end + elseif kind == QTE.PATH then + thisMove = Q.scanInput(q) + if thisMove ~= QTE.NOMOVE then + local p = q.path[q.currentMove] + local slot = nil + + for _, at in ipairs({ 1, 3, 5, 7 }) do + if thisMove == p[at] then + slot = at + break + end + end + if slot == nil then + q.bShowAction = false + q.lvlState = QTE.branch02 + else + q.iPath = p[slot + 1] + if q.iPath > 1000 then + m[QTE.moveDeath] = q.iPath - 1000 + q.bShowAction = false + q.iPath = 0 + q.lvlState = QTE.branch02 + else + if slot == 1 then + q.iPathAend, q.iPathAjmp = p[4] - 1, p[9] + elseif slot == 3 then + if p[5] == 0 then + q.iPathAend = 0 + q.iPathAjmp = (p[9] == QTE.OUT) and QTE.OUT or 0 + else + q.iPathAend, q.iPathAjmp = p[6] - 1, p[9] + end + elseif slot == 5 then + if p[4] == 0 then + q.iPathAend = 0 + q.iPathAjmp = (p[9] == QTE.OUT) and QTE.OUT or 0 + else + q.iPathAend, q.iPathAjmp = p[8] - 1, p[9] + end + else + q.iPathAend = 0 + q.iPathAjmp = (p[9] == QTE.OUT) and QTE.OUT or 0 + end + q.bShowAction = false + Q.good(q) + end + end + end + elseif kind == QTE.YESNO then + local p = q.path[q.currentMove] + + thisMove = Q.scanInput(q) + if thisMove ~= QTE.NOMOVE then + if thisMove == QTE.BUTTON1 then + q.iPath = p[1] + if q.iPath > 1000 then + m[QTE.moveDeath], q.bShowAction, q.iPath, q.lvlState = q.iPath - 1000, false, 0, QTE.branch02 + else + q.iPathAend, q.iPathAjmp, q.bShowAction = p[2] - 1, p[3], false + Q.good(q) + end + else + q.bShowAction = false + q.lvlState = QTE.branch02 + end + elseif q.currentFrame == m[QTE.inputFrmEnd] then + q.iPath = p[2] + if q.iPath > 1000 then + m[QTE.moveDeath], q.bShowAction, q.iPath, q.lvlState = q.iPath - 1000, false, 0, QTE.branch02 + else + q.iPathAend, q.iPathAjmp, q.bShowAction = 0, 0, false + Q.good(q) + end + end + elseif kind == QTE.TIMED then + local i, j = m[5] or q.currentMove, m[6] or q.currentMove + + thisMove = Q.scanInput(q) + if thisMove ~= QTE.NOMOVE then + if q.bTime then + q.bTime = false + q.Hit = q.currentFrame + end + for tcount = i, j do + local t = q.timed[tcount] + + if (q.Hit >= t[2]) and (q.Hit <= t[3]) then + if t[1] == thisMove then + if t[5] ~= nil then + if t[5] == 0 then + discSkipToFrame(m[QTE.moveFrmEnd]) + elseif t[5] > 0 then + q.iPath = t[5] + discSkipToFrame(m[QTE.moveFrmEnd]) + end + end + Q.good(q) + else + q.iPauseFrame, q.bShowAction, m[QTE.moveDeath], q.lvlState = m[QTE.inputFrmEnd], false, t[4], QTE.branch02 + end + break + elseif tcount == j then + q.iPauseFrame, m[QTE.moveDeath], q.bShowAction, q.lvlState = m[QTE.inputFrmEnd], t[4], false, QTE.branch02 + end + end + end + elseif kind == QTE.SKIP then + Q.checkSkip(q, Q.scanInput(q), kind) + end +end + + +function Q.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 = 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.bPath, q.bTime, q.bCalc = true, true, true + q.bShowScore = (q.dip_GameType ~= 2) and (q.dip_Display == 0) + q.bShowLives = q.bShowScore + if not q.bSave then + q.currentMove = 1 + end + Q.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) + q.bShowGet = true + q.lvlState = QTE.branch08 + q.bRes = false + else + Q.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 + 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 + elseif q.lvlState == QTE.branch03 then + if Q.timerDue(q) then + 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.lvlState = QTE.lvlPlayRest + 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 + elseif q.lvlState == QTE.branch05 then + if Q.timerDue(q) then + q.bGOAlt = true + if Q.beatGameWithOneLife(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.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 + 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 + Q.startGame(q) + end + elseif q.lvlState == QTE.branch07 then + if q.currentFrame >= q.iFrameEnd then + Q.afterDeath(q, thisLevel) + 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.enterScene(q, thisLevel) + end + elseif q.lvlState == QTE.branch09 then + if q.currentFrame >= q.iFrameEnd then + Q.resumeAfterGetReady(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 Q.sceneDone(q, thisLevel) then + q.lvlState = QTE.lvlEnd + end + elseif q.lvlState == QTE.lvlPlayRest then + q.bPlayRight = true + q.thisMove = Q.scanInput(q) + if q.currentFrame >= q.move[q.currentMove][QTE.moveFrmEnd] then + if q.iPathAjmp == QTE.OUT then + q.currentMove = q.iPathAjmp + q.iPathAend, q.iPathAjmp = 0, 0 + q.lvlState = QTE.branch12 + return + 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 m = q.move[q.currentMove] + + if ((q.currentFrame + 2) <= m[QTE.moveFrmStart]) or (q.currentFrame > m[QTE.moveFrmStart]) then + discSkipToFrame(m[QTE.moveFrmStart]) + end + q.bShowAction = false + q.bPlayPrompt = true + if m[QTE.correctMove] == QTE.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 + end + end + end + elseif q.lvlState == QTE.lvlRunning then + local m = q.move[q.currentMove] + + if m == nil then + q.lvlState = QTE.lvlEnd + 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 (m[QTE.correctMove] < 50) 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 + else + q.lvlState = QTE.lvlPlayRest + end + else + q.thisMove = Q.scanInput(q) + end + elseif q.lvlState == QTE.lvlPlayDeath then + if q.currentFrame >= q.iFrameEnd then + if q.ShowSupDeath and (q.dip_GameType ~= 3) then + Q.setupClip(q, q.offsetSupDeath, q.offsetSupDeathEnd) + q.lvlState = QTE.branch07 + else + Q.afterDeath(q, thisLevel) + 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 = Q.newScore(q, q.iScore) and QTE.levelHighScore or QTE.levelGameOver + end + else + if Q.beatLevel(q, thisLevel) then + q.stage[thisLevel][QTE.BEATSTATUS] = true + q.iScene = 0 + Q.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 + Q.addPoints(q, q.PERFECTBONUS, 0) + 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.sound(q, "sndclear") + q.currentLevel = QTE.levelFinish + else + q.iScoreTemp, q.iBonus = 0, 0 + if q.dip_PlayStyle == 3 then + q.currentLevel = QTE.levelMap + 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 + end + if not q.scene[thisLevel][q.iScene][QTE.SCENECOMPLETE] 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.bAllowSave, q.bSave = q.iLives, q.iScore, true, false + end + 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 + end + end +end + + +-- The level-cleared screen: the clip, then the bonus rolled into the score. +function Q.doFinish(q) + q.bShowScore, q.bRes = false, true + if q.lvlState == QTE.lvlSetup then + Q.setupClip(q, q.offsetClear, q.offsetClearend) + q.lvlState = QTE.branch01 + elseif q.lvlState == QTE.branch01 then + if q.currentFrame >= q.iFrameEnd then + discPause() + Q.timerON(q, 0.1) + q.lvlState = QTE.branch02 + end + elseif q.lvlState == QTE.branch02 then + if Q.timerDue(q) then + if q.iBonus > 0 then + q.iBonus = q.iBonus - 500 + q.iScoreTemp = q.iScoreTemp + 500 + Q.timerON(q, 0.01) + else + Q.sound(q, "sndvictory") + Q.timerON(q, 2) + q.lvlState = QTE.branch03 + end + end + elseif q.lvlState == QTE.branch03 then + if Q.timerDue(q) then + q.iScoreTemp, q.iBonus, q.numTrophy = 0, 0, 0 + if q.dip_PlayStyle == 3 then + q.lvlState = QTE.lvlSetup + q.currentLevel = QTE.levelMap + else + q.iWrongMv = 0 + Q.nextLevel(q, q.iLevel) + q.lvlState = QTE.lvlSetup + q.currentLevel = QTE.levelNormal + end + end + 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 + 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 + q.iScene = q.iScene - 1 + end + Q.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 Q.newScore(q, q.iScore) then + q.currentLevel = QTE.levelHighScore + q.bGOAlt = true + else + q.currentLevel = QTE.levelGameOver + end + end +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 + if q.bGOAlt then + Q.setupClip(q, q.offsetGameOverAlt, q.offsetGameOverAltend) + q.bGOAlt = false + elseif q.currentFrame == q.offsetContinueend then + 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 + 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 difficulty screen's stick: LEFT and RIGHT step through the four frames. +function Q.moveFrameDiff(q) + local frame = q.currentFrame + local step = nil + + if (frame == q.frameEasy) and q.p1RIGHT then + step = { q.frameNormal, "p1RIGHT", 1 } + elseif (frame == q.frameNormal) and q.p1LEFT then + step = { q.frameEasy, "p1LEFT", 0 } + elseif (frame == q.frameNormal) and q.p1RIGHT then + step = { q.frameHard, "p1RIGHT", 2 } + elseif (frame == q.frameHard) and q.p1LEFT then + step = { q.frameNormal, "p1LEFT", 1 } + elseif (frame == q.frameHard) and q.p1RIGHT then + step = { q.frameExtreme, "p1RIGHT", 3 } + elseif (frame == q.frameExtreme) and q.p1LEFT then + step = { q.frameHard, "p1LEFT", 2 } + end + if step then + Q.sound(q, "sndcoin") + discSkipToFrame(step[1]) + discPause() + q[step[2]] = false + q.dip_Difficulty = step[3] + end +end + + +-- The in-game difficulty screen: a still per difficulty, the stick to choose, the action button +-- or thirty seconds to take it. +function Q.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 + 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 + 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 + else + q.currentLevel = QTE.levelNormal + end + end +end + + +-- The high score board: its clips, the name left unentered (what is typed is not play), and +-- on to the game over. +function Q.doHighScore(q) + if q.lvlState == QTE.lvlSetup then + q.bShowLives, q.bShowScore, q.bShowCredits, q.bShowAction = false, false, 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 + if q.iFrameEnd + 1 ~= q.offsetRankings then + Q.setupClip(q, q.offsetRankings, q.offsetRankingsend) + else + q.iFrameEnd = q.offsetRankingsend + end + q.lvlState = QTE.branch02 + end + elseif q.lvlState == QTE.branch02 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 + end +end + + +-- ----- The game's own Lua: its settings script and its add-ons, run over the loop's state --- + +-- A file the game's Lua names under its MYDIR, as a name beside the description: MYDIR is +-- Forge's (the description's directory) whatever the script set it to. +function Q.gameFile(q, name) + local mine = q.MYDIR or "" + local stem = mine:match("([^/\\]+)$") + + 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 + -- port carries them flat beside itself. + if stem and (name:sub(1, #stem + 7) == "singe/" .. stem .. "/") then + return name:sub(#stem + 8) + end + + return name +end + + +-- A game's picture, found where the port carries it (a sheet of so many frames across when +-- a count is given); -1 when the file is not there, as a stub gave, so a game's load never +-- ends on missing art. One handle per file for the game's life: the games load a crosshair +-- or a flash again every frame they show it (some without unloading), so a load answers with +-- the handle it gave before, and an unload is nothing. +function Q.loadSprite(q, name, frames) + local path = authorFile(Q.gameFile(q, tostring(name))) + local key = path .. "#" .. tostring(frames or 0) + + q.spriteCache = q.spriteCache or {} + if q.spriteCache[key] ~= nil then + return q.spriteCache[key] + end + local probe = io.open(path, "rb") + local handle = -1 + + if probe ~= nil then + probe:close() + if frames then + handle = spriteLoadFrames(path, frames) + else + handle = spriteLoad(path) + end + end + q.spriteCache[key] = handle + + return handle +end + + +-- What a game's own code draws is drawn when Forge draws: the shim queues each sprite, text, +-- box, colour, and font call as it comes (a behaviour steps before the overlay is cleared, so +-- a call made then would be wiped), and the game's look runs the queue in order. +function Q.queueDraw(q, op) + q.draws = q.draws or {} + q.draws[#q.draws + 1] = op +end + + +-- The queue run, then emptied; a look calls this, then draws its own. +function Q.flushDraws(q) + for _, op in ipairs(q.draws or {}) do + op() + end + q.draws = {} +end + + +-- The queue emptied without drawing (a frame with no look to run it). +function Q.dropDraws(q) + q.draws = {} +end + + +-- A handle a game holds is a sprite when the load found the file. +local function isHandle(handle) + return (type(handle) == "number") and (handle >= 0) +end + + +-- The framework's font colours (the Karis 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 } } + +function Q.fontColour(q, which) + local c = FONT_COLOURS[which] or ((which == 12) and { q.miscR or 255, q.miscG or 255, q.miscB or 255 }) or nil + + if c then + colorForeground(c[1], c[2], c[3]) + Q.queueDraw(q, function() colorForeground(c[1], c[2], c[3]) end) + end +end + + +-- The dips a service screen changed, kept in the engine's save store for this game and read +-- back over the description's (the shipped defaults) when the loop attaches. The names are +-- the description's dips. +function Q.configLoad(q) + local text = saveGet("forge.cfg") + + if type(text) ~= "string" then + return + end + local chunk = load(text, "config", "t", {}) + local saved = chunk and chunk() or nil + + if type(saved) ~= "table" then + return + end + for name in pairs(q.snapshot.dips or {}) do + if saved[name] ~= nil then + q[name] = saved[name] + end + end + -- The board the game keeps in the same file as its dips (the American Laser Games editions). + if (type(saved.highscore) == "table") and (type(q.highscore) == "table") then + q.highscore = saved.highscore + end +end + + +function Q.configSave(q) + local saved = {} + + for name in pairs(q.snapshot.dips or {}) do + saved[name] = q[name] + end + if type(q.highscore) == "table" then + saved.highscore = q.highscore + end + saveSet("forge.cfg", "return " .. sourceOf(saved)) +end + + +-- The globals a game's script sees: the state table for what it names there, the loop's +-- helpers under the framework's names, and the engine for the rest. What it assigns lands in +-- the state. +function Q.shim(q) + local helpers = { + timerON = function(seconds) Q.timerON(q, seconds) end, + timerDue = function() return Q.timerDue(q) end, + timerOFF = function() q.timerLimit = nil end, + joyDelayON = function(seconds) Q.joyDelayON(q, seconds) end, + joyDelayDue = function() return Q.joyDelayDue(q) end, + setupClip = function(from, to) Q.setupClip(q, from, to) end, + addPoints = function(thisMuch, thisValue) Q.addPoints(q, thisMuch, thisValue) end, + scanInput = function() return Q.scanInput(q) end, + getMiddle = function(phrase) + local spr = fontToSprite(tostring(phrase)) + local x = (q.OVLW or overlayGetWidth()) / 2 - spriteGetWidth(spr) * 0.5 + + spriteUnload(spr) + return x + end, + getMiddle2 = function() return 0 end, + getMiddle3 = function() return 0 end, + setFontColor = function(which) Q.fontColour(q, which) end, + fontSelect = function(handle) + if isHandle(handle) then + fontSelect(handle) + Q.queueDraw(q, function() fontSelect(handle) end) + end + end, + fontQuality = function(quality) + fontQuality(quality) + Q.queueDraw(q, function() fontQuality(quality) end) + end, + colorForeground = function(r, g, b, a) + colorForeground(r, g, b, a or 255) + Q.queueDraw(q, function() colorForeground(r, g, b, a or 255) end) + end, + -- The games draw with the older argument order (x, y, sprite); a sheet's frame the same + -- way (x, y, frame, sprite). + spriteDraw = function(...) + local first = select(1, ...) + + if type(first) == "number" and (select("#", ...) >= 3) then + local x, y, handle = ... + + if isHandle(handle) then + Q.queueDraw(q, function() spriteDraw(handle, x, y) end) + 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) + end + end, + fontPrint = function(x, y, text) + local line = tostring(text) + + Q.queueDraw(q, function() fontPrint(x, y, line) end) + end, + overlayBox = function(...) + local args = { ... } + + Q.queueDraw(q, function() overlayBox(table.unpack(args)) end) + end, + overlayPrint = function(...) + local args = { ... } + + Q.queueDraw(q, function() overlayPrint(table.unpack(args)) end) + 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). + spriteLoadFrames = function(a, b) + if type(a) == "number" then + return Q.loadSprite(q, b, a) + end + return Q.loadSprite(q, a, b) + end, + spriteUnload = function() end, + spriteResetColorKey = function() end, + spriteGetWidth = function(handle) + if isHandle(handle) then + return spriteGetWidth(handle) + end + return 0 + end, + spriteGetHeight = function(handle) + if isHandle(handle) then + return spriteGetHeight(handle) + end + return 0 + end, + soundPlay = function(handle) + if type(handle) == "string" then + Q.sound(q, handle) + elseif type(handle) == "number" and (handle >= 0) then + soundPlay(handle) + end + end, + soundLoad = function(file) + -- A map-mode game keeps the name: its handle is the file, played by name. + if q.namedSounds then + local name = Q.gameFile(q, file) + + q.sounds[name] = name + return name + end + return -1 + end, + soundStop = function(handle) if type(handle) == "number" and (handle >= 0) then soundStop(handle) end end, + soundPause = function(handle) if type(handle) == "number" and (handle >= 0) then soundPause(handle) end end, + soundResume = function(handle) if type(handle) == "number" and (handle >= 0) then soundResume(handle) end end, + soundIsPlaying = function() return false end, + soundSetVolume = function() end, + discAudio = function() end, + videoLoad = function() return -1 end, + musicLoad = function(name) return musicLoad(authorFile(Q.gameFile(q, name))) end, + -- A font named by its place in the game's tree (the typing edition's), found where the + -- port carries it. + fontLoad = function(name, size) return fontLoad(authorFile(Q.gameFile(q, name)), size) end, + videoPlay = function() end, + videoUnload = function() end, + videoSeek = function() end, + videoSetVolume = function() end, + videoDraw = function() end, + overlayClear = function() end, + colorForeground = function() end, + autoSave = function() end, + writeSave = function() end, + loadSave = function() end, + cfgReadPath = function(name) return singeGetDataPath() .. "Cfg/" .. name end, + 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. + if q.loadsFiles then + Q.runScript(q, Q.gameFile(q, name)) + 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 + -- read; what it sets, the description sets again after the load. + require = function(name) + if name == "lfs" then + return { attributes = function() return nil end, mkdir = function() end } + end + return {} + end, + io = { input = function(path) + q.ioReading = (type(path) == "string") and io.open(path, "rb") or nil + return q.ioReading + end, + output = function() return nil end, + read = function(what) + if q.ioReading then + return q.ioReading:read(what or "l") + end + return nil + end, + write = function() end, + close = function(handle) + if handle and (handle == q.ioReading) then + q.ioReading:close() + q.ioReading = nil + end + end, + open = function() return nil end, + lines = function() return function() return nil end end } + } + + return setmetatable({}, { + __index = function(_, key) + if key == "MYDIR" then + -- Where the game is, Forge says -- with the slash the game's own MYDIR ended in, + -- since a script may join a file name straight onto it. A game that took its + -- directory from the script's path (the later map-mode copies, whose files sit + -- in Script/ under it) has it right already. + local own = q.gameMYDIR or "" + + if (own ~= "") and (q.MYDIR:sub(1, #own) == own) then + return own + end + return q.MYDIR .. (string.match(own, "[/\\]$") and "/" or "") + end + local value = rawget(q, key) + + if value ~= nil then + return value + end + value = helpers[key] + if value ~= nil then + return value + end + + return _G[key] + end, + __newindex = function(_, key, value) + -- A script's own MYDIR names the Hypseus 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 + else + q[key] = value + end + end + }) +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 + -- loading the game inside itself. + local path = authorBeside(name) + local chunk, why = loadfile(path, "t", q.env) + + if chunk == nil then + debugPrint("Author: qte could not load " .. tostring(path) .. ": " .. tostring(why)) + return false + end + local ok, err = xpcall(chunk, debug.traceback) + + if not ok then + debugPrint("Author: qte running " .. tostring(name) .. ": " .. tostring(err):sub(1, 1500)) + return false + end + + return true +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.", + params = {}, + attach = function(instance, params) + local data = AUTHOR_GAME.qte or {} + local q = { instance = instance, snapshot = data } + + instance.q = q + instance.hearsAll = true + -- The framework's constants, then the description's snapshot of the game's settings, + -- under the names the game's own Lua uses. + 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(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 + end + 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. + for _, name in ipairs({ "Level", "Death", "Tiers", "PlayOrder", "LvlOrder", "LvlMap", "move", "choice", "path", "timed", "stage", "scene", "sprite", "sprArrow", "sprNUM", "Group1", "Group2", "Group3", "Group4" }) do + q[name] = q[name] or {} + end + -- The game's own script declares the tables and setupMoves; its add-ons hook the loop. + -- Both run in the shim, so what they set is the state and what they read is the state. + if data.script then + Q.runScript(q, data.script) + end + if data.addons then + Q.runScript(q, data.addons) + end + q.sounds = {} + for _, name in ipairs({ "right", "wrong", "death", "victory", "coin", "credit", "clear", "roll" }) do + 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.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 + q.iLives, q.iLevel, q.currentMove, q.iContinues, q.iPath, q.iPathAend, q.iPathAjmp, q.i2P = 0, 1, 0, 0, 0, 0, 0, 0 + q.iMash, q.iMulti, q.iLoopStep, q.iLoopPrev, q.iLenHold, q.lastHold, q.lenCounter, q.mashCounter, q.unMash = 0, 0, 0, 0, 0, 0, 8, 5, 0.07 + q.iRightMv, q.iWrongMv, q.iScPlayed, q.iScDeath, q.iTotDeath, q.iLifeBar, q.iPauseFrame, q.iTempLevel, q.numTrophy = 0, 0, 0, 0, 0, q.BarSize or 10, 0, 0, 0 + q.thisMove, q.currentFrame, q.iFrameStart, q.iFrameEnd, q.thisScore, q.Hit = QTE.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.gameflow = "vldp" + q.currentLevel = QTE.levelIntro + q.lvlState = QTE.lvlSetup + q.move, q.path, q.choice, q.timed, q.totalMoves, q.sceneStart, q.sceneEnd = {}, {}, {}, {}, 0, 0, 0 + q.stage, q.scene, q.LvlOrder = {}, {}, {} + Q.clearInput(q) + q.p1START1, q.p1START2, q.p1COIN1, q.p1COIN2, q.p1SERVICE = false, false, false, false, false + Q.initStages(q) + if q.MovieFPS then + discSetFPS(q.MovieFPS) + end + end, + step = function(instance) + local q = instance.q + + q.currentFrame = discGetFrame() + if q.gameflow == "vldp" then + -- The framework's start: the title clip once through, a frame of init, the attract loop. + if q.lvlState == QTE.lvlSetup then + Q.setupClip(q, q.offsetTitle, q.offsetTitleend) + 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.gameflow = "init" + end + return + end + if q.gameflow == "init" then + q.gameflow = "running" + q.lvlState = QTE.lvlSetup + return + end + if q.currentLevel == QTE.levelIntro then + Q.doIntro(q) + elseif q.currentLevel == QTE.levelNormal then + Q.doLevel(q) + elseif q.currentLevel == QTE.levelMap then + if q.doLevelSelect then + q.doLevelSelect() + else + q.currentLevel = QTE.levelNormal + q.lvlState = QTE.lvlSetup + end + elseif q.currentLevel == QTE.levelContinue then + Q.doContinue(q) + elseif q.currentLevel == QTE.levelGameOver then + Q.doGameOver(q) + elseif q.currentLevel == QTE.levelHighScore then + Q.doHighScore(q) + elseif q.currentLevel == QTE.levelFinish then + Q.doFinish(q) + elseif q.currentLevel == QTE.levelDiffScreen then + Q.doDiffSelect(q) + 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 + end, + on = function(instance, name, event) + local q = instance.q + local flag = qteFlagOf(event.switch or -1) + + if flag == nil then + return + end + if name == "pressed" then + q[flag] = true + if (flag == "p1BUTTON1") and q.bTestMash then + q.iMash = q.iMash + 1 + end + if (flag == "p1BUTTON1") and q.bTestMashL then + q.iMash = q.iMash + 1 + end + if (flag == "p1BUTTON2") and q.bTestMashR then + q.iMash = q.iMash + 1 + end + if (flag == "p1LEFT") and q.bTestRunL then + q.iMash = q.iMash + 1 + end + 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 + if q.bTestMashR then + q.iMash = q.iMash + 1 + end + if q.bTestMashL then + q.iMash = q.iMash + 1 + end + end + if q.bTestMulti and (flag ~= "p1START1") and (flag ~= "p1START2") and (flag ~= "p1COIN1") and (flag ~= "p1COIN2") and (flag ~= "p1SERVICE") then + q.iMulti = q.iMulti + 1 + Q.sound(q, "sndroll") + end + elseif name == "released" then + if (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 + q.iCoins = q.iCoins + 1 + if q.iCoins >= q.dip_CoinsPerCredit then + q.iCoins = q.iCoins - q.dip_CoinsPerCredit + q.iCredits = q.iCredits + 1 + Q.sound(q, "sndcredit") + else + Q.sound(q, "sndcoin") + end + end + else + q[flag] = false + end + end + end +} + + +-- ===== The Kimmy Script Engine game 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: +-- 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. + +local K = {} -- The loop's functions. +local KIMMY = { + -- Kimmy'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, + CHOOSE = 50, PATH = 51, YESNO = 52, TIMED = 53, ANYTHING = 54, NOTHING = 55, + SKIP = 100, WAY = 101, WAYOUT = 102, TOLEVEL = 103, TOSCENE = 104, + ACTUP = 120, ACTDOWN = 121, ACTLEFT = 122, ACTRIGHT = 123, RUN = 124, MASH2 = 125, MASHMIN = 126, MASHMAX = 127, MASH2MIN = 128, MASH2MAX = 129, + RUNMIN = 130, RUNMAX = 131, HOLDUP = 132, HOLDDOWN = 133, HOLDLEFT = 134, HOLDRIGHT = 135, HOLDBUT = 136, LOOPLEFT = 137, LOOPRIGHT = 138, + DOUBLE = 150, UD = 151, DU = 152, LR = 153, RL = 154, B1B2 = 155, + NOMOVE = -1, MOVEPENDING = -2, MOVEFAIL = -3, OUT = 1000, + levelNG = 118, level2P = 115, level2PEnd = 116, levelSelect = 117, + branch13 = 22 +} +-- The masks: one bit per input, in the order the framework keeps them. +local KIMMY_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 function maskOf(kind) + local bits = { 0, 0, 0, 0, 0, 0, 0 } + + for i, bit in ipairs(KIMMY_MASK[kind] or {}) do + bits[i] = bit + end + + return bits +end + + +-- How many bits of the held mask agree with the wanted one, and whether a wanted bit is held +-- that the move does not want: the framework's countcombo loop. +local function maskAgrees(held, wanted) + local count = 0 + local extra = false + + for i = 1, 7 do + if held[i] == wanted[i] then + count = count + 1 + elseif (held[i] == 1) and (wanted[i] == 0) then + extra = true + end + end + + return count, extra +end + + +-- ----- The state a move leaves behind --------------------------------------------------------- + +function K.resetArrows(q) + q.bShowAction = false +end + + +function K.resetVar(q) + K.resetArrows(q) + q.bTestMash, q.bTestMashL, q.bTestMashR = false, false, false + q.bTestRunL, q.bTestRunR, q.bTestRunU, q.bTestRunD = false, false, false, false + q.bTestHold, q.bTestCombo, q.bTestMulti = false, false, false + q.iMash, q.iLenHold, q.iMulti = 0, 0, 1 + q.bCalc = true + q.acombo = { 0, 0, 0, 0, 0, 0, 0 } +end + + +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 + q.iPauseFrame = m[QTE.inputFrmEnd] + q.bShowAction = false + q.lvlState = QTE.branch02 + end +end + + +-- The mask a move asks for, from the column that names it; once per move (bCalc). +function K.fillMove(q, column) + local m = q.move[q.currentMove] + + q.bTestCombo = true + if not q.bCalc then + return + end + q.bCalc = false + q.gcombo = maskOf(m[column]) + if m[QTE.correctMove] <= KIMMY.DOWNRIGHT then + if m[5] ~= nil then + q.g2combo = maskOf(m[5]) + if m[5] == KIMMY.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 + for i, bit in ipairs(maskOf(m[6])) do + if bit == 1 then + q.gcombo[i] = 1 + end + end + end +end + + +-- ----- The tests, one per kind of move -------------------------------------------------------- + +function K.checkAny(q, curMove) + if q.p1UP or q.p1DOWN or q.p1LEFT or q.p1RIGHT or q.p1BUTTON1 or q.p1BUTTON2 or q.p1BUTTON3 then + return curMove + end + + return KIMMY.MOVEPENDING +end + + +function K.checkBasic(q, curMove) + local m = q.move[q.currentMove] + local z = KIMMY.MOVEPENDING + local count, extra = maskAgrees(q.acombo, q.gcombo) + local count2 = 0 + + if extra then + z = KIMMY.MOVEFAIL + end + if m[5] ~= nil then + local extra2 + + count2, extra2 = maskAgrees(q.acombo, q.g2combo) + if extra2 then + z = KIMMY.MOVEFAIL + end + end + if (count == 7) or (count2 == 7) then + z = curMove + end + + return z +end + + +function K.checkCombo(q, curMove) + local count, extra = maskAgrees(q.acombo, q.gcombo) + + if count == 7 then + return curMove + end + + return extra and KIMMY.MOVEFAIL or KIMMY.MOVEPENDING +end + + +function K.checkHold(q, curMove) + local m = q.move[q.currentMove] + local z = KIMMY.MOVEPENDING + local count, extra = maskAgrees(q.acombo, q.gcombo) + + if extra then + z = KIMMY.MOVEFAIL + end + if (q.currentFrame == m[QTE.inputFrmStart]) and (count == 7) then + z = KIMMY.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 + if q.bTestHold and (q.currentFrame == q.lastHold) then + q.lastHold = q.currentFrame + elseif q.bTestHold and (q.currentFrame == q.lastHold + 1) then + q.iLenHold = q.iLenHold + 1 + q.lastHold = q.currentFrame + else + q.lastHold = q.currentFrame + end + end + elseif count == 5 then + if z ~= KIMMY.NOMOVE then + q.iLenHold = 0 + else + if q.iLenHold > 0 then + q.iLenHold = q.iLenHold - 1 + end + z = KIMMY.MOVEPENDING + end + end + + return z +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 count, extra = maskAgrees(q.acombo, q.gcombo) + + if extra then + q.iMash = 0 + z = KIMMY.MOVEFAIL + end + if count == 7 then + q.bTestMash, q.bTestRunU, q.bTestRunD, q.bTestRunL, q.bTestRunR = false, false, false, false, false + if q.iMash >= q.mashCounter then + z = curMove + q.p1BUTTON1, q.p1UP, q.p1DOWN, q.p1LEFT, q.p1RIGHT = false, false, false, false, false + end + elseif q.iMash > 0 then + q.iMash = q.iMash - q.unMash + end + + return z +end + + +function K.checkMashBB(q, playerMove, curMove) + local z = KIMMY.MOVEPENDING + + if (playerMove == KIMMY.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 + q.bTestMashR, q.bTestMashL = false, true + if q.iMash >= q.mashCounter then + z = curMove + q.p1BUTTON2 = false + end + else + if q.iMash > 0 then + q.iMash = q.iMash - q.unMash + end + if playerMove ~= KIMMY.NOMOVE then + q.iMash = 0 + z = KIMMY.MOVEFAIL + end + end + + return z +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 + + if playerMove == KIMMY.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 + q.bTestRunL, q.bTestRunR, q.p1RIGHT = true, false, false + if q.iMash >= q.mashCounter then + z = curMove + end + elseif playerMove == KIMMY.UP then + q.p1UP = false + elseif playerMove == KIMMY.DOWN then + q.p1DOWN = false + else + if q.iMash > 0 then + q.iMash = q.iMash - q.unMash + end + if playerMove ~= KIMMY.NOMOVE then + q.iMash = 0 + z = KIMMY.MOVEFAIL + end + end + + return z +end + + +function K.checkMashUD(q, playerMove, curMove) + local z = KIMMY.MOVEPENDING + + if playerMove == KIMMY.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 + q.bTestRunU, q.bTestRunD, q.p1DOWN = true, false, false + if q.iMash >= q.mashCounter then + z = curMove + end + elseif playerMove == KIMMY.LEFT then + q.p1LEFT = false + elseif playerMove == KIMMY.RIGHT then + q.p1RIGHT = false + else + if q.iMash > 0 then + q.iMash = q.iMash - q.unMash + end + if playerMove ~= KIMMY.NOMOVE then + q.iMash = 0 + z = KIMMY.MOVEFAIL + end + end + + return z +end + + +function K.checkMulti(q, curMove) + local m = q.move[q.currentMove] + local z = KIMMY.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 + return z + end + local count, extra = maskAgrees(q.acombo, q.mcombo) + + if extra then + z = KIMMY.MOVEFAIL + end + if (count == 7) and (q.iMulti < #steps) then + q.iMulti = q.iMulti + 1 + q.acombo = { 0, 0, 0, 0, 0, 0, 0 } + elseif (count == 7) and (q.iMulti == #steps) then + z = curMove + end + + return z +end + + +function K.checkNo(q, curMove) + local m = q.move[q.currentMove] + local z = KIMMY.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 + end + if (q.currentFrame == m[QTE.inputFrmEnd]) and (z == KIMMY.MOVEPENDING) then + z = curMove + end + + return z +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 + q.lvlState = QTE.branch02 + else + q.iPathAend = endAt + q.iPathAjmp = jumpTo + q.lvlState = QTE.branch04 + end +end + + +-- ----- The move being waited for, judged by its kind ----------------------------------------- + +function K.doMove(q) + local m = q.move[q.currentMove] + local kind = m[QTE.correctMove] + local fps = q.MovieFPS + + if kind == KIMMY.HOLD then + q.bTestHold = true + if q.bCalc then + q.lenCounter = (m[QTE.inputFrmEnd] - m[QTE.inputFrmStart]) - (14 * (fps / 24) - q.dip_Difficulty) + end + 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) + K.resultMove(q) + elseif kind == KIMMY.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 + q.bTestMash = true + elseif with == KIMMY.UP then + q.bTestRunU = true + elseif with == KIMMY.DOWN then + q.bTestRunD = true + elseif with == KIMMY.LEFT then + q.bTestRunL = true + elseif with == KIMMY.RIGHT then + q.bTestRunR = true + elseif with == KIMMY.UPLEFT then + q.bTestRunU, q.bTestRunL = true, true + elseif with == KIMMY.UPRIGHT then + q.bTestRunU, q.bTestRunR = true, true + elseif with == KIMMY.DOWNLEFT then + q.bTestRunD, q.bTestRunL = true, true + elseif with == KIMMY.DOWNRIGHT then + q.bTestRunD, q.bTestRunR = true, true + end + if q.bCalc then + local adjust = m[6] or 0 + + 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 + q.mashCounter = q.mashCounter * 2 + q.unMash = q.unMash * 1.5 + elseif with == KIMMY.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 + 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 + q.bTestMashL, q.bTestMashR = true, true + q.mashCounter = q.mashCounter * 2.5 + end + end + if with == KIMMY.UD then + q.thisMove = K.checkMashUD(q, q.thisMove, kind) + elseif with == KIMMY.LR then + q.thisMove = K.checkMashLR(q, q.thisMove, kind) + elseif with == KIMMY.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 + K.fillMove(q, 3) + q.thisMove = K.checkBasic(q, kind) + K.resultMove(q) + elseif kind == KIMMY.ANYTHING then + q.thisMove = K.checkAny(q, kind) + K.resultMove(q) + elseif kind == KIMMY.NOTHING then + q.thisMove = K.checkNo(q, kind) + K.resultMove(q) + elseif kind == KIMMY.COMBO then + K.fillMove(q, 5) + q.thisMove = K.checkCombo(q, kind) + K.resultMove(q) + elseif (kind == KIMMY.MULTI) or (kind == KIMMY.LOOP) then + q.bTestCombo, q.bTestMulti = true, true + if kind == KIMMY.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 + q.thisMove = Q.scanInput(q) + if q.thisMove ~= KIMMY.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) + 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) + 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) + else + q.bShowAction = false + q.lvlState = QTE.branch02 + end + end + elseif kind == KIMMY.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 + takeBranch(q, p[1], p[2] - 1, p[3]) + else + q.bShowAction = false + q.lvlState = QTE.branch02 + end + elseif q.currentFrame == m[QTE.inputFrmEnd] then + takeBranch(q, p[2], 0, 0) + end + elseif kind == KIMMY.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.bTime then + q.bTime = false + q.Hit = q.currentFrame + end + for tcount = i, j do + local t = q.timed[tcount] + + if (q.Hit >= t[2]) and (q.Hit <= t[3]) then + if t[1] == q.thisMove then + if t[5] ~= nil then + if t[5] == 0 then + discSkipToFrame(m[QTE.moveFrmEnd]) + elseif t[5] > 0 then + q.iPath = t[5] + discSkipToFrame(m[QTE.moveFrmEnd]) + end + end + q.lvlState = QTE.branch04 + else + q.iPauseFrame, q.bShowAction, m[QTE.moveDeath], q.lvlState = m[QTE.inputFrmEnd], false, t[4], QTE.branch02 + end + break + elseif tcount == j then + q.iPauseFrame, m[QTE.moveDeath], q.bShowAction, q.lvlState = m[QTE.inputFrmEnd], t[4], false, QTE.branch02 + end + end + end + elseif kind == KIMMY.SKIP then + q.thisMove = K.checkSkip(q, Q.scanInput(q), kind) + end +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 function wordOf(text) + return KIMMY_WORD[string.upper(text)] +end + + +-- The words of a comma-separated list, as the framework splits it: the last word is taken +-- whether or not a comma follows it. +local function wordsOf(text) + local words = {} + + for word in string.gmatch(text .. ",", "([^,]*),") do + words[#words + 1] = word + end + + return words +end + + +-- The framework's shorthand kinds (ACTUP, HOLDLEFT, RUNMAX, LOOPLEFT, DOUBLE, ...) rewritten +-- as the kind they stand for, and the multi, loop, path, and yes/no texts read into tables. +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 + -- "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" + 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 turn = (string.sub(text, 3, 3) == "+") and clockwise or widdershins + + for i = 2, count do + steps[i] = turn[steps[i - 1]] + end + local button = wordOf(string.sub(text, 7, 8)) + + if button and (button >= KIMMY.BUTTON1) and (button <= KIMMY.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 + -- The same input so many times. + local steps = {} + + for i = 1, m[6] do + steps[i] = m[5] + end + q.multi[at] = steps + m[6] = nil + elseif kind == KIMMY.MULTI then + local steps = {} + + for i, word in ipairs(wordsOf(m[5])) do + steps[i] = wordOf(word) or KIMMY.BUTTON1 + end + q.multi[at] = steps + elseif (kind == KIMMY.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 } + local slot = 1 + + for _, word in ipairs(wordsOf(m[5])) do + if slot % 2 == 1 then + p[slot] = wordOf(word) or 0 + else + p[slot] = tonumber(word) + end + slot = slot + 1 + end + p[9] = m[6] or KIMMY.OUT + q.path[at] = p + elseif (kind == KIMMY.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 + 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 } + 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 function mirrored(value) + return KIMMY_SIDE[value] or value +end + + +-- The framework's setupFramesMoves: game type 5 makes every move a button; otherwise the +-- shortcuts, the easy difficulty's simplifications, the mash and hold dips, mirroring, and +-- the frames each move plays from and to. +function K.setupFramesMoves(q) + local move = q.move + + for at = 1, q.totalMoves do + 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 + end + else + K.getShortcuts(q, at) + if q.dip_Difficulty == 0 then + if m[3] == KIMMY.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 + m[3], m[5] = q.multi[at][1], nil + elseif m[3] == KIMMY.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 + 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 + end + end + if (q.dip_HoldtoLoop == 1) and (m[3] == KIMMY.LOOP) then + local way = string.sub(m[5], 3, 3) + + if way == "+" then + m[3], m[5] = KIMMY.HOLD, KIMMY.RIGHT + elseif way == "-" then + m[3], m[5] = KIMMY.HOLD, KIMMY.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 + 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]] + end + end + elseif m[3] == KIMMY.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]] + end + t[2] = t[2] + q.offsetFlip + t[3] = t[3] + q.offsetFlip + end + elseif m[3] == KIMMY.COMBO then + m[5] = mirrored(m[5]) + m[6] = mirrored(m[6]) + elseif m[3] == KIMMY.MULTI then + for i, step in ipairs(q.multi[at]) do + q.multi[at][i] = mirrored(step) + end + elseif m[3] == KIMMY.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] + 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 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 + m[7] = move[at - 2][2] + 1 + else + m[7] = before[2] + 1 + end + m[8] = (at < q.totalMoves) and m[2] or q.sceneEnd + end +end + + +function K.setupFrames(q, thisLevel) + local level = q.Level[thisLevel] + local w = q.RelativeFrames and 1 or 0 + local move = q.move + + q.offsetFlip = q.bFlip and level[QTE.MIRROR] or 0 + q.sceneStart = q.sceneStart + w * level[QTE.INTROCLIP] + q.offsetFlip + q.sceneEnd = q.sceneEnd + w * level[QTE.INTROCLIP] + q.offsetFlip + q.Tlimit = q.sceneStart + 50 + for k = 1, q.totalMoves do + move[k][1] = move[k][1] + w * level[QTE.INTROCLIP] + q.offsetFlip + q.iPenal + move[k][2] = move[k][2] + w * level[QTE.INTROCLIP] + q.offsetFlip + end + K.setupFramesMoves(q) +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() + 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) + if q.dip_Hints and (q.dip_GameType ~= 1) and (q.dip_GameType ~= 2) and (q.move[q.currentMove][QTE.moveDeath] >= 0) then + Q.sound(q, "sndwrong") + q.bShowLvl, q.bShowScore, q.bShowLives, q.bShowNext, q.bShowWarnTilt = false, false, false, false, false + discSkipToFrame(q.frameHints) + Q.timerON(q, 2) + discPause() + q.lvlState = QTE.branch03 + else + if q.iPath > KIMMY.OUT then + q.iPath = 0 + end + q.iWrongMv = q.iWrongMv + 1 + q.iScDeath = q.iScDeath + 1 + 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 + K.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) + end + end +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) + q.p1BUTTON1 = false + discSkipToFrame(frame) + discPause() + q.bShowLCD, q.bShowCredits = false, false + Q.timerON(q, seconds) + q.lvlState = nextState + end + local function clip(from, to, nextState) + q.p1BUTTON1 = false + q.bShowLCD, q.bShowCredits = true, true + Q.setupClip(q, from, to) + q.lvlState = nextState + end + local function secret() + 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.bShowCredits = false + return true + end + return false + end + local ended = (q.currentFrame >= q.iFrameEnd) or q.p1BUTTON1 + + if q.lvlState == QTE.lvlSetup then + Q.setupClip(q, q.offsetIntro01, q.offsetIntro01end) + q.lvlState = QTE.branch01 + q.bShowCredits, q.bShowLCD, q.bShowLives, q.bCheckForCredits = true, true, false, true + elseif q.lvlState == QTE.branch01 then + if ended then + still(q.frameControls, 10, (q.frameSpecial ~= q.frameControls) and QTE.branch02 or QTE.branch03) + else + secret() + end + elseif q.lvlState == QTE.branch02 then + if Q.timerDue(q) or q.p1BUTTON1 then + still(q.frameSpecial, 10, QTE.branch03) + end + elseif q.lvlState == QTE.branch03 then + if Q.timerDue(q) or q.p1BUTTON1 then + clip(q.offsetIntro02, q.offsetIntro02end, QTE.branch04) + end + elseif q.lvlState == QTE.branch04 then + if ended then + still(q.frameRankings, 15, QTE.branch05) + else + secret() + end + elseif q.lvlState == QTE.branch05 then + if Q.timerDue(q) or q.p1BUTTON1 then + clip(q.offsetIntro03, q.offsetIntro03end, (q.LvlTrophy3 ~= 0) and QTE.branch06 or QTE.branch09) + else + secret() + end + elseif q.lvlState == QTE.branch06 then + if ended then + still(q.frameTrophy, 10, QTE.branch07) + else + secret() + end + elseif q.lvlState == QTE.branch07 then + if Q.timerDue(q) or q.p1BUTTON1 then + still(q.frameTrophy, 10, QTE.branch08) + end + elseif q.lvlState == QTE.branch08 then + if Q.timerDue(q) or q.p1BUTTON1 then + clip(q.offsetTitle, q.offsetTitleend, QTE.branch09) + else + secret() + end + elseif q.lvlState == QTE.branch09 then + if ended then + still(q.frameRankingsAlt, 15, QTE.branch10) + else + secret() + end + elseif q.lvlState == QTE.branch10 then + if Q.timerDue(q) or q.p1BUTTON1 then + q.p1BUTTON1 = false + q.bShowLCD, q.bShowCredits = false, false + q.lvlState = QTE.lvlSetup + end + end + local free = (q.dip_CoinsPerCredit == QTE.DOPT_FREEPLAY) + + 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 + q.dip_StartLevel, q.dip_StartScene = 1, 1 + end + if (q.dip_GameType == 2) or (q.dip_GameType == 3) then + K.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 + else + q.lvlState = QTE.lvlSetup + K.startGame(q) + end + end + end + if free or (q.iCredits > 1) then + if q.p1START2 then + q.p1START2 = false + if ((q.dip_GameType == 0) or (q.dip_GameType == 3)) and not free then + q.iCredits = q.iCredits - 2 + end + q.lvlState = QTE.lvlSetup + q.i2P = 1 + 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 + end + end +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] +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 + end + q.iContinues, q.bLvlJump = 0, false + if q.i2P == 0 then + q.iLevel = q.dip_StartLevel + q.iScene = q.dip_StartScene - 1 + else + q.iScene = q.iScene - 1 + end + q.iScoreTemp = q.iScore + if q.dip_GameType == 1 then + livesForType(q) + end + q.iTop = topFor(q) + q.currentLevel = QTE.levelNormal + q.lvlState = QTE.lvlSetup + 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.bShowNext = false, false, false, false + q.bShowCredits, q.bShowLCD, q.bResetContinue, q.bExtendedPlay = false, false, false, false +end + + +function K.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 + livesForType(q) + else + q.iLives = q.dip_LivesPerCredit + end + q.iTop = 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 + q.bShowGet, q.bShowSkip, q.bShowCredits, q.bShowLCD, q.bResetContinue, q.bExtendedPlay = false, false, false, false, false, false +end + + +-- Whether the autosave slot holds a game just begun, which is no game to continue. +local function autosaveIsFresh(q) + local save = slotOf(q, 4) + + if save == nil then + return true + end + local line = save.line + + return (tonumber(line[1]) == 1) and ((tonumber(line[2]) == 1) or (tonumber(line[2]) == 0)) and (tonumber(line[3]) == 1) +end + + +-- The new-game menu of a free-play game: NEW GAME, CONTINUE (the autosave), BACK. +function K.updateNG(q, index) + if index == 2 then + q.dip_StartLevel, q.dip_StartScene = 1, 1 + K.startGame(q) + elseif index == 3 then + if autosaveIsFresh(q) then + Q.sound(q, "sndwrong") + else + K.loadSave(q, 4) + end + elseif index == 4 then + q.gameflow = "init" + elseif index == 10 then + K.startGame(q) + end +end + + +function K.doNG(q) + local optMax = 4 + + if q.lvlState == QTE.lvlSetup then + if autosaveIsFresh(q) then + K.startGame(q) + else + discSkipToFrame(q.frameNewGame) + discPause() + q.timerLimit = nil + q.optSel = 2 + q.bShowLvl, q.bShowScore, q.bShowLives, q.bShowSkip, q.bShowCredits = false, false, false, false, false + q.bShowGet, q.bShowLCD, q.bShowAction, q.bShowNext, q.bIgnoreJoy = false, false, false, false, false + q.bShowWarnTilt, q.bShowTilt = false, false + q.lvlState = QTE.lvlRunning + end + elseif q.lvlState == QTE.lvlRunning then + if q.p1BUTTON1 then + if q.bIgnoreJoy then + if Q.joyDelayDue(q) then + q.bIgnoreJoy = false + end + else + q.p1BUTTON1 = false + Q.sound(q, "sndcredit") + K.updateNG(q, q.optSel) + q.bIgnoreJoy = true + Q.joyDelayON(q, 0.250) + end + elseif q.p1BUTTON2 then + if q.bIgnoreJoy then + if Q.joyDelayDue(q) then + q.bIgnoreJoy = false + end + else + q.p1BUTTON2 = false + Q.sound(q, "sndcredit") + if ((q.optSel == 2) and (q.dip_GameType ~= 4)) or q.bUnlockSel then + K.updateNG(q, 10) + else + K.updateNG(q, q.optSel) + end + q.bIgnoreJoy = true + Q.joyDelayON(q, 0.250) + end + elseif q.p1DOWN or q.p1UP then + if q.bIgnoreJoy then + if Q.joyDelayDue(q) then + q.bIgnoreJoy = false + end + else + if q.p1DOWN then + q.p1DOWN = false + q.optSel = q.optSel + 1 + if q.optSel > optMax then + q.optSel = 2 + end + else + q.p1UP = false + q.optSel = q.optSel - 1 + if q.optSel < 2 then + q.optSel = optMax + end + end + Q.sound(q, "sndcoin") + q.bIgnoreJoy = true + Q.joyDelayON(q, 0.250) + end + end + end +end + + +-- The level select: a still per level, LEFT and RIGHT to step, the button to take one not +-- yet beaten, a minute to decide. +function K.moveFrameLevel(q) + if q.p1LEFT then + q.dip_StartLevel = q.dip_StartLevel - 1 + if q.dip_StartLevel < 1 then + q.dip_StartLevel = q.finalstage + end + Q.sound(q, "sndcoin") + q.p1LEFT = false + elseif q.p1RIGHT then + q.dip_StartLevel = q.dip_StartLevel + 1 + if q.dip_StartLevel > q.finalstage then + q.dip_StartLevel = 1 + end + Q.sound(q, "sndcoin") + q.p1RIGHT = false + end + local level = q.Level[q.dip_StartLevel] + + if level[8] ~= nil then + discSkipToFrame(q.RelativeFrames and (level[2] + level[8]) or level[8]) + else + discSkipToFrame(level[2] + 100) + end + discPause() +end + + +function K.doLvlSelect(q) + if q.altState == QTE.lvlSetup then + q.bShowScore, q.bShowLives, q.bIgnoreJoy = false, false, false + if q.finalstage > 1 then + local level = q.Level[q.dip_StartLevel] + + if level[8] ~= nil then + discSkipToFrame(q.RelativeFrames and (level[2] + level[8]) or level[8]) + else + discSkipToFrame(level[2] + 100) + end + discPause() + Q.timerON(q, 60) + q.altState = QTE.lvlRunning + else + 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 + q.currentLevel = QTE.levelNormal + end + end + elseif q.altState == QTE.lvlRunning then + if Q.timerDue(q) then + q.altState = QTE.lvlEnd + elseif q.p1BUTTON1 then + if not q.stage[q.dip_StartLevel][QTE.BEATSTATUS] then + Q.sound(q, "sndcredit") + q.p1BUTTON1 = false + q.altState = QTE.lvlEnd + else + Q.sound(q, "sndwrong") + end + elseif q.p1LEFT or q.p1RIGHT then + K.moveFrameLevel(q) + end + elseif q.altState == QTE.lvlEnd then + q.iLevel = q.dip_StartLevel + q.lvlState = QTE.lvlSetup + q.currentLevel = QTE.levelNormal + end +end + + +-- The level cleared: the clear clip, the bonus counted into the score, and on. +function K.doClear(q) + q.bShowScore, q.bRes = false, true + q.bShowWarnTilt, q.bShowTilt = false, false + if q.lvlState == QTE.lvlSetup then + Q.setupClip(q, q.offsetClear, q.offsetClearend) + q.lvlState = QTE.branch01 + elseif q.lvlState == QTE.branch01 then + if q.currentFrame >= q.iFrameEnd then + discPause() + Q.timerON(q, 0.1) + q.lvlState = QTE.branch02 + end + elseif q.lvlState == QTE.branch02 then + if Q.timerDue(q) then + if q.iBonus > 0 then + q.iBonus = q.iBonus - 500 + q.iScoreTemp = q.iScoreTemp + 500 + Q.timerON(q, 0.01) + else + Q.sound(q, "sndvictory") + Q.timerON(q, 2) + q.lvlState = QTE.branch03 + end + end + elseif q.lvlState == QTE.branch03 then + if Q.timerDue(q) then + q.iScoreTemp, q.iBonus, q.numTrophy, q.iTiltMv = 0, 0, 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 + toLevelSelect(q) + else + q.iWrongMv = 0 + K.nextLevel(q, q.iLevel) + q.lvlState = QTE.lvlSetup + q.currentLevel = QTE.levelNormal + end + end + end +end + + +-- The percent game's finish: the clear clip, the score of the moves, and a button to quit, +-- replay, or take another level. +function K.doFinish(q) + q.bShowScore, q.bRes = false, true + q.bShowWarnTilt, q.bShowTilt = false, false + if q.lvlState == QTE.lvlSetup then + Q.setupClip(q, q.offsetClear, q.offsetClearend) + q.lvlState = QTE.branch01 + elseif q.lvlState == QTE.branch01 then + if q.currentFrame >= q.iFrameEnd then + discPause() + Q.timerON(q, 2) + q.lvlState = QTE.branch02 + end + elseif q.lvlState == QTE.branch02 then + local percent = math.floor(100 * (q.iRightMv / (q.iRightMv + q.iWrongMv))) - 5 * q.iTiltMv + + if percent < 0 then + percent = 0 + end + q.percentMade = percent + if Q.timerDue(q) then + local restart = nil + + if q.p1BUTTON1 then + q.bNoScreen = false + elseif q.p1BUTTON2 then + q.bNoScreen = true + restart = true + elseif q.p1BUTTON3 then + q.bNoScreen = false + q.dip_StartLevel = (q.dip_StartLevel < q.finalstage) and (q.dip_StartLevel + 1) or 1 + restart = true + end + if q.p1BUTTON1 or q.p1BUTTON2 or q.p1BUTTON3 then + q.iScoreTemp, q.iBonus, q.numTrophy = 0, 0, 0 + Q.sound(q, "sndcredit") + if restart then + K.startGame(q) + end + end + end + end +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) + if q.lvlState == QTE.lvlSetup then + discSkipToFrame(q.frameQuit) + discPause() + q.timerLimit = nil + q.optSel = 1 + q.bShowLvl, q.bShowScore, q.bShowLives, q.bShowSkip, q.bShowCredits = false, false, false, false, false + q.bShowGet, q.bShowLCD, q.bShowAction, q.bShowNext, q.bIgnoreJoy = false, false, false, false, false + q.bShowWarnTilt, q.bShowTilt = false, false + q.lvlState = QTE.lvlRunning + elseif q.lvlState == QTE.branch01 then + if q.currentFrame >= q.iFrameEnd then + singeQuit() + end + elseif q.lvlState == QTE.lvlRunning then + if q.p1BUTTON1 then + if q.bIgnoreJoy then + if Q.joyDelayDue(q) then + q.bIgnoreJoy = false + end + else + q.p1BUTTON1 = false + Q.sound(q, "sndcredit") + if q.optSel == 1 then + if q.offsetQuit ~= 0 then + Q.setupClip(q, q.offsetQuit, q.offsetQuitend) + q.lvlState = QTE.branch01 + else + singeQuit() + end + elseif q.optSel == 2 then + q.lvlState = QTE.lvlEnd + else + q.bInPlayExit = false + q.lvlState = QTE.lvlEnd + end + q.bIgnoreJoy = true + Q.joyDelayON(q, 0.250) + end + elseif q.p1DOWN or q.p1UP then + if q.bIgnoreJoy then + if Q.joyDelayDue(q) then + q.bIgnoreJoy = false + end + else + if q.p1DOWN then + q.p1DOWN = false + q.optSel = (q.optSel % 4) + 1 + else + q.p1UP = false + q.optSel = (q.optSel == 1) and 4 or (q.optSel - 1) + end + Q.sound(q, "sndcoin") + q.bIgnoreJoy = true + Q.joyDelayON(q, 0.250) + end + end + elseif q.lvlState == QTE.lvlEnd then + if q.bInPlayExit then + q.bInPlayExit = false + K.loadSave(q, 4) + else + q.gameflow = "init" + end + end +end + + +-- ----- The behaviour ------------------------------------------------------------------------- + +-- START with COIN held, or the console key: out of play to the quit menu, with an autosave +-- the port does not write. +function K.exitPlay(q) + 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 < 2)) then + q.altState = QTE.branch01 + end + end + q.lvlState = QTE.lvlSetup + q.currentLevel = QTE.levelExit +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.", + params = {}, + attach = function(instance, params) + local data = AUTHOR_GAME.qte or {} + local q = { instance = instance, snapshot = data, kimmy = true } + + instance.q = q + instance.hearsAll = true + -- The framework's constants -- Kimmy'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 + 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[name] = (q[name] == 1) or (q[name] == true) + end + q.addPoints = K.addPoints + 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) + 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 {} + end + if data.script then + Q.runScript(q, data.script) + end + if data.addons then + Q.runScript(q, data.addons) + end + q.sounds = {} + for _, name in ipairs({ "right", "wrong", "death", "victory", "coin", "credit", "clear", "roll" }) do + 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.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. + q.frameRankingsAlt = q.frameRankingsAlt or q.frameRankings + q.frameQuit = q.frameQuit or q.frameHints + q.frameNewGame = q.frameNewGame or q.frameHints + if q.offsetQuit == nil then + q.offsetQuit, q.offsetQuitend = 0, 1 + end + q.iCoins, q.iCredits, q.iScore, q.iScoreTemp, q.iBonus, q.iScene, q.iExtraLife = 0, 0, 0, 0, 0, 0, 0 + q.iLives, q.iLevel, q.currentMove, q.iContinues, q.iPath, q.iPathAend, q.iPathAjmp, q.i2P = 0, 1, 0, 0, 0, 0, 0, 0 + 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.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 + q.bTestMash, q.bTestMashL, q.bTestMashR, q.bTestRunL, q.bTestRunR, q.bTestRunU, q.bTestRunD = false, false, false, false, false, false, false + q.bTestHold, q.bTestCombo, q.bTestMulti, q.bShowWarnTilt, q.bShowTilt = false, false, false, false, false + q.b1PStart, q.b2PStart, q.b1PEnd, q.b2PEnd = false, false, false, false + q.acombo, q.gcombo, q.g2combo, q.mcombo = { 0, 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0 } + q.gameflow = "vldp" + q.currentLevel = QTE.levelIntro + q.lvlState = QTE.lvlSetup + q.move, q.path, q.choice, q.timed, q.multi, q.totalMoves, q.sceneStart, q.sceneEnd = {}, {}, {}, {}, {}, 0, 0, 0 + q.stage, q.scene, q.LvlOrder = {}, {}, {} + Q.clearInput(q) + q.p1START1, q.p1START2, q.p1COIN1, q.p1COIN2, q.p1SERVICE, q.p1BUTTON4 = false, false, false, false, false, false + Q.initStages(q) + if q.MovieFPS then + discSetFPS(q.MovieFPS) + end + end, + step = function(instance) + local q = instance.q + + q.currentFrame = discGetFrame() + if q.gameflow == "vldp" then + if q.lvlState == QTE.lvlSetup then + Q.setupClip(q, q.offsetTitle, q.offsetTitleend) + 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.gameflow = "init" + end + return + end + if q.gameflow == "init" then + q.gameflow = "running" + q.currentLevel = QTE.levelIntro + q.lvlState = QTE.lvlSetup + q.iCoins, q.iScore, q.iScoreTemp, q.iBonus, q.iScene = 0, 0, 0, 0, 0 + q.bShowCredits = true + return + end + local level = q.currentLevel + + if level == QTE.levelIntro then + K.doIntro(q) + elseif level == QTE.levelNormal then + K.doLevel(q) + elseif level == QTE.levelMap then + if q.doLevelSelect then + q.doLevelSelect() + else + q.currentLevel = QTE.levelNormal + q.lvlState = QTE.lvlSetup + end + elseif level == QTE.levelDiffScreen then + K.doDiffSelect(q) + elseif level == KIMMY.levelSelect then + K.doLvlSelect(q) + elseif level == QTE.levelContinue then + K.doContinue(q) + elseif level == QTE.levelGameOver then + K.doGameOver(q) + elseif level == QTE.levelHighScore then + K.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 + K.doNG(q) + elseif level == QTE.levelExit then + K.doExit(q) + elseif (level == QTE.levelMovie) or (level == KIMMY.level2P) or (level == KIMMY.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 + 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 + local switch = event.switch or -1 + local flag = qteFlagOf(switch) + + if (name == "released") and (switch == SWITCH_PAUSE) then + q.bPause = not q.bPause + return + end + if flag == nil then + return + end + if name == "pressed" then + if q.bPause then + return + end + q[flag] = true + local bit = KIMMY_BIT[flag] + + if bit then + if q.bTestCombo then + q.acombo[bit] = 1 + end + if ((flag == "p1UP") and q.bTestRunU) or ((flag == "p1DOWN") and q.bTestRunD) or ((flag == "p1LEFT") and q.bTestRunL) or ((flag == "p1RIGHT") and q.bTestRunR) then + q.iMash = q.iMash + 1 + end + if (bit >= 5) and q.bTestMash then + q.iMash = q.iMash + 1 + end + if ((flag == "p1BUTTON1") and q.bTestMashL) or ((flag == "p1BUTTON2") and q.bTestMashR) then + q.iMash = q.iMash + 1 + end + if q.bTestMulti then + Q.sound(q, "sndroll") + end + elseif (flag == "p1COIN1") and q.p1START1 and (q.currentLevel ~= QTE.levelContinue) then + q.p1START1 = false + K.exitPlay(q) + elseif (flag == "p1START1") and q.p1COIN1 and (q.currentLevel ~= QTE.levelContinue) then + q.p1COIN1 = false + K.exitPlay(q) + end + 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 + q.iCoins = q.iCoins + 1 + if q.iCoins >= q.dip_CoinsPerCredit then + q.iCoins = q.iCoins - q.dip_CoinsPerCredit + q.iCredits = q.iCredits + 1 + Q.sound(q, "sndcredit") + else + Q.sound(q, "sndcoin") + end + if q.currentLevel == QTE.levelContinue then + q.bResetContinue = true + end + end + else + q[flag] = false + local bit = KIMMY_BIT[flag] + + if bit and q.bTestCombo then + q.acombo[bit] = 0 + end + end + end + end +} + + +-- ===== RDG's map-mode game loop ============================================================= +-- +-- The rdg behaviour plays a game of RDG's "LUA SINGE 1.0/1.1" 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 -- +-- the segments (createLevelNN), the moves (setupLevelNN, some drawn at random), the level's +-- offsets (SetupFramesLevel), the intro clip (getIntroClip), the death's clip (setupDeathClip), +-- the order of levels (NextLevel), and the map's cursor -- so the game's files are loaded into +-- the shim and those functions are called where the ancestor calls them, over the loop's +-- state; the loop itself is this. The numbers are the game's own, read from its globals. + +local R = {} + + +function R.addPoints(q, thisMuch) + q.iScore = q.iScore + thisMuch + if q.iScore > 99999999 then + q.iScore = 99999999 + end + if q.iTop and (q.iScore > q.iTop) then + q.iTop = q.iScore + end +end + + +-- The ancestor reads the inputs and lets them all go. +function R.scanInput(q) + local result = q.NOMOVE + + if q.p1UP then result = q.UP + elseif q.p1DOWN then result = q.DOWN + elseif q.p1LEFT then result = q.LEFT + elseif q.p1RIGHT then result = q.RIGHT + elseif q.p1BUTTON1 then result = q.BUTTON1 + elseif q.p1BUTTON2 then result = q.BUTTON2 + elseif q.p1BUTTON3 then result = q.BUTTON3 end + Q.clearInput(q) + + return result +end + + +-- The sound of a right move: the framework's sndright, or the one the game's tweaks name +-- (Freedom Fighter shoots). +local function rightSound(q) + return q[q.tweaks.rightSound or "sndright"] +end + + +function R.checkMash(q, playerMove, curMove) + local z = q.MOVEPENDING + + if playerMove == q.BUTTON1 then + q.p1BUTTON1 = false + if q.iMash >= q.mashCounter + q.dip_Difficulty then + z = curMove + q.iMash = 0 + Q.sound(q, rightSound(q)) + q.p1BUTTON1 = false + end + elseif playerMove ~= q.NOMOVE then + q.iMash = 0 + z = q.MOVEFAIL + end + + return z +end + + +function R.checkSkip(q, playerMove, curMove) + if ((not q.later) and (q.myButton == SWITCH_BUTTON3)) or ((playerMove >= q.UP) and (playerMove <= q.BUTTON3)) then + Q.clearInput(q) + q.bShowAction = false + discSkipToFrame(q.move[q.currentMove][q.inputFrmEnd]) + end + + return curMove +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 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" }, + [q.UPLEFT] = { "p1UP", "p1LEFT" }, [q.UPRIGHT] = { "p1UP", "p1RIGHT" }, [q.DOWNLEFT] = { "p1DOWN", "p1LEFT" }, [q.DOWNRIGHT] = { "p1DOWN", "p1RIGHT" } } + end + + return RDG_PAIRS[kind] +end + + +local function rightMove(q, silent) + local worth = q.SCOREMOVE + q.dip_Difficulty * q.BUFFMOVE + + if not silent then + Q.sound(q, rightSound(q)) + end + R.addPoints(q, worth) + q.iScoreTemp = q.iScoreTemp + worth + q.lvlState = QTE.lvlPlayRest +end + + +local function wrongMove(q) + q.iPauseFrame = q.move[q.currentMove][q.inputFrmEnd] + q.bShowAction = false + q.setupDeathClip(q.thisMove) +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) + if not q.stage[thisLevel][q.LEVELSTARTED] then + q.stage[thisLevel][q.LEVELSTARTED] = true + if q.bSave and (q.currentMove ~= 1) then + q.currentFrame = q.move[q.currentMove - 1][q.inputFrmEnd] + 1 + discSkipToFrame(q.currentFrame) + q.bSave = false + q.lvlState = QTE.lvlRunning + elseif (not q.bSkipIntroClip) and ((q.dip_StartLevel ~= thisLevel) or (q.dip_StartSegment == 1)) then + q.bShowLvl, q.bShowSkip = true, true + q.getIntroClip(thisLevel) + q.lvlState = QTE.branch01 + else + discSkipToFrame(q.segmentStart) + q.lvlState = QTE.lvlRunning + end + else + if q.currentFrame + 1 ~= q.segmentStart then + discSkipToFrame(q.segmentStart) + end + q.lvlState = QTE.lvlRunning + end +end + + +-- The next move is a choice (the later copies' CHOOSE kind). +local function nextIsChoice(q) + local after = q.CHOOSE and q.move[q.currentMove + 1] + + return (after ~= nil) and (after[q.correctMove] == q.CHOOSE) +end + + +-- A death clip has ended: a life gone, and on by the rewind dip. +local function afterDeath(q, thisLevel) + q.iLives = q.iLives - 1 + q.stage[thisLevel][q.DEATHCOUNT] = q.stage[thisLevel][q.DEATHCOUNT] + 1 + if q.iLives <= 0 then + q.lvlState = QTE.lvlEnd + return + end + local rewind = q.dip_Rewind or 0 + + if rewind == 0 then + if q.later then + q.bRes = true + q.lvlState = QTE.lvlEnd + else + q.lvlState = QTE.branch07 + end + elseif rewind == 3 then + q.bRes = q.later or q.bRes + q.iSegPointer = 0 + q.lvlState = QTE.lvlSetup + elseif (rewind == 2) and ((q.currentMove == q.totalMoves) or (q.later and nextIsChoice(q))) then + q.segment[q.iCurPos][q.iSegPointer][q.SEGMENTCOMPLETE] = true + q.lvlState = QTE.lvlEnd + elseif q.later then + discSkipToFrame(q.iPauseFrame) + q.bShowScore, q.bShowLives = true, true + q.currentMove = q.currentMove + 1 + q.lvlState = QTE.lvlRunning + else + q.lvlState = QTE.branch09 + end +end + + +function R.doLevel(q) + local thisLevel = q.iCurPos + + if q.lvlState == QTE.lvlSetup then + q.bPlayPrompt = true + q.bShuffleOrder = true + q.bShowLives, q.bShowLvl, q.bShowScene, q.bShowAction = true, false, false, false + q.bAct, q.bTestMash, q.iMash = false, false, 0 + if not q.bSave then + q.currentMove = 1 + end + q.setupLevel(thisLevel) + if q.later and q.ShowResurrect and q.bRes then + -- The later copies can play a "get up" clip before the retry. + q.bShowGet = true + Q.setupClip(q, q.offsetResurrect, q.offsetResurrectEnd) + q.lvlState = QTE.branch09 + q.bRes = false + else + q.bShowScore = true + startSegment(q, thisLevel) + end + elseif q.lvlState == QTE.branch01 then + if 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 + discSkipToFrame(q.segmentStart) + end + q.lvlState = QTE.lvlRunning + end + elseif q.lvlState == QTE.branch02 then + R.doChoose(q) + elseif q.lvlState == QTE.branch03 then + if q.currentFrame == q.move[q.currentMove][q.moveFrmStart] then + q.lvlState = QTE.lvlRunning + end + elseif q.lvlState == QTE.branch04 then + if q.currentFrame >= q.segmentEnd then + q.lvlState = QTE.lvlEnd + end + elseif q.lvlState == QTE.branch05 then + if clipEnded(q) then + for j = 1, q.stage[thisLevel][q.SEGMENTCOUNT] do + q.segment[thisLevel][j][q.SEGMENTCOMPLETE] = true + end + q.lvlState = QTE.lvlEnd + end + elseif q.lvlState == QTE.branch06 then + if Q.timerDue(q) then + q.bGOAlt = true + local fresh = (thisLevel ~= q.levelExt) and q.bAllowSecret and (q.dip_StartLevel == 1) and (q.dip_StartSegment == 1) + + -- The extended play's wait: branch07 in the later copies, branch08 in the first. + local extended = q.later and QTE.branch07 or QTE.branch08 + + if fresh and q.BeatGameWithOneLife() then + Q.sound(q, q.sndvictory) + R.addPoints(q, q.SCORESECRET) + q.iBonus = q.iBonus + q.SCORESECRET + discSkipToFrame(q.frameExtendedPlay) + discPause() + Q.timerON(q, 4) + q.lvlState = extended + elseif fresh and q.BeatGameWithOneCredit() then + Q.sound(q, q.sndvictory) + discSkipToFrame(q.frameExtendedPlay) + discPause() + Q.timerON(q, 4) + q.lvlState = extended + elseif q.newScore(q.iScore) then + q.lvlState = QTE.lvlSetup + q.currentLevel = QTE.levelHighScore + else + q.lvlState = QTE.lvlSetup + q.currentLevel = QTE.levelGameOver + end + end + elseif (q.lvlState == QTE.branch07) and q.later then + -- The extended play begins. + if Q.timerDue(q) then + q.bExtendedPlay = true + q.iSegPointer = 0 + R.startGame(q) + end + elseif q.lvlState == QTE.branch07 then + -- The death clip has ended (no rewind). + if q.currentFrame == q.iFrameEnd then + q.lvlState = QTE.lvlEnd + end + elseif (q.lvlState == QTE.branch08) and q.later then + -- The extra death clip has ended. + if clipEnded(q) then + afterDeath(q, thisLevel) + end + elseif q.lvlState == QTE.branch08 then + if Q.timerDue(q) then + q.bExtendedPlay = true + q.iSegPointer = 0 + R.startGame(q) + end + elseif (q.lvlState == QTE.branch09) and q.later then + -- The "get up" clip has ended: the segment begins. + if clipEnded(q) then + q.bShowGet, q.bShowScore = false, true + startSegment(q, thisLevel) + end + elseif q.lvlState == QTE.branch09 then + -- The rewind: back to the move after the death. + if q.currentFrame == q.iFrameEnd then + discSkipToFrame(q.iPauseFrame) + q.currentMove = q.currentMove + 1 + q.lvlState = QTE.lvlRunning + end + elseif q.lvlState == QTE.lvlPlayRest then + local m = q.move[q.currentMove] + + if q.currentMove < q.totalMoves then + q.bCheckMove = true + end + q.thisMove = R.scanInput(q) + if q.thisMove ~= q.NOMOVE then + Q.sound(q, q.sndwrong) + end + if q.currentFrame == m[q.moveFrmEnd] then + q.bCheckMove = false + q.currentMove = q.currentMove + 1 + if q.currentMove <= q.totalMoves then + local next = q.move[q.currentMove] + + if q.currentFrame + 1 ~= next[q.moveFrmStart] then + discSkipToFrame(next[q.moveFrmStart]) + end + q.bShowAction, q.bPlayPrompt, q.bAct, q.bTestMash, q.iMash = false, true, false, false, 0 + if q.later and (q.move[q.currentMove][q.correctMove] == q.CHOOSE) then + q.altState = QTE.lvlSetup + q.lvlState = QTE.branch02 + else + q.lvlState = QTE.lvlRunning + end + else + local bonus = q.SCORESCENE - q.stage[thisLevel][q.DEATHCOUNT] * q.DEATHPENALTY + + q.segment[thisLevel][q.iSegPointer][q.SEGMENTCOMPLETE] = true + if ((thisLevel == q.finalstage) and q.stageBeat(q.finalstage)) or ((thisLevel == q.levelExt) and ((not q.later) or q.stageBeat(q.levelExt))) then + R.addPoints(q, bonus) + if (q.dip_StartLevel == 1) and (q.dip_StartSegment == 1) then + R.addPoints(q, q.SCOREGAME) + else + R.addPoints(q, q.SCORELEVEL) + end + if thisLevel == q.levelExt then + discPause() + Q.timerON(q, 0.1) + else + Q.sound(q, q.sndvictory) + discSkipToFrame(q.frameVictory) + discPause() + Q.timerON(q, 3) + end + q.lvlState = QTE.branch06 + else + if q.stage[thisLevel][q.DEATHCOUNT] < 5 then + R.addPoints(q, bonus) + q.iBonus = q.iBonus + bonus + end + q.lvlState = QTE.branch04 + end + end + end + elseif q.lvlState == QTE.lvlRunning then + local m = q.move[q.currentMove] + local kind = m[q.correctMove] + + -- Easy or kid mode: the harder kinds become the button, a mash a little later. + if ((q.dip_Difficulty == 0) and (kind >= 6) and (kind <= 12)) or q.dip_Kidmode then + if kind == q.MASH then + m[q.inputFrmStart] = m[q.inputFrmStart] + 10 + end + m[q.correctMove] = q.BUTTON1 + kind = q.BUTTON1 + end + local inWindow = (q.currentFrame >= m[q.inputFrmStart]) and (q.currentFrame <= m[q.inputFrmEnd]) + local pair = pairFor(q, kind) + + if (q.currentFrame > m[q.inputFrmEnd]) and (kind ~= q.SKIP) then + q.iPauseFrame = m[q.inputFrmEnd] + q.bShowAction, q.bTestMash, q.iMash = false, false, 0 + q.setupDeathClip(q.thisMove) + elseif q.currentFrame > m[q.inputFrmEnd] then + q.bShowAction = false + q.lvlState = QTE.lvlPlayRest + elseif inWindow and pair then + q.bShowAction = true + 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. + local shot = (kind == q.ACTDOWN) and (m[7] ~= nil) + + for _, name in ipairs({ "p1UP", "p1DOWN", "p1LEFT", "p1RIGHT", "p1BUTTON1", "p1BUTTON2", "p1BUTTON3" }) do + if q[name] and (name ~= pair[1]) and (name ~= pair[2]) then + other = true + end + end + if (m[q.inputFrmEnd] - q.currentFrame <= 0) or other then + wrongMove(q) + elseif shot then + if q.p1BUTTON1 and (q.mouseX >= m[7]) and (q.mouseX <= m[9]) and (q.mouseY >= m[8]) and (q.mouseY <= m[10]) then + q.bShowAction, q.bAct = false, false + q.p1BUTTON1, q.p1DOWN = false, false + rightMove(q, true) + discSkipForward(m[q.inputFrmEnd] - discGetFrame() - 2) + end + elseif q[pair[1]] and q[pair[2]] then + q.bShowAction, q.bAct = false, false + q[pair[1]], q[pair[2]] = false, false + rightMove(q) + end + elseif inWindow then + q.thisMove = R.scanInput(q) + q.bShowScene, q.bShowLvl, q.bShowAction = false, false, true + if q.bPlayPrompt then + Q.sound(q, q.sndprompt) + q.bPlayPrompt = false + end + if q.thisMove ~= q.NOMOVE then + if kind == q.MASH then + q.bTestMash = true + q.thisMove = R.checkMash(q, q.thisMove, kind) + elseif kind == q.SKIP then + q.thisMove = R.checkSkip(q, q.thisMove, kind) + end + if q.thisMove == kind then + q.bShowAction, q.bTestMash, q.iMash = false, false, 0 + if kind ~= q.SKIP then + rightMove(q) + else + q.lvlState = QTE.lvlPlayRest + end + elseif q.thisMove ~= q.MOVEPENDING then + wrongMove(q) + end + end + else + R.scanInput(q) + end + elseif q.lvlState == QTE.lvlPlayDeath then + q.bShowScene = false + if q.later then + q.bShowScore = false + end + if 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) + q.lvlState = QTE.branch08 + else + afterDeath(q, thisLevel) + end + end + elseif q.lvlState == QTE.lvlEnd then + q.lvlState = QTE.lvlSetup + if q.iLives == 0 then + if q.dip_AllowContinue and ((q.iContinues < q.dip_LimitContinue) or (q.dip_LimitContinue == q.DOPT_INFINITE_CONTINUES)) then + q.iTempLevel = q.currentLevel + q.currentLevel = QTE.levelContinue + q.iContinues = q.iContinues + 1 + else + q.currentLevel = q.newScore(q.iScore) and QTE.levelHighScore or QTE.levelGameOver + end + else + if q.stageBeat(thisLevel) then + q.stage[thisLevel][q.BEATSTATUS] = true + q.levelMap[thisLevel] = true + if (q.later and (q.dip_GameType == 3)) or ((not q.later) and q.bAllowMap) then + -- The ancestor autosaves here; the port keeps no saves. + q.bShowDiskA = true + q.altState = QTE.branch01 + end + q.iSegPointer = 0 + if q.choiceMode and (q.iCurPos == q.choiceLevel) then + q.lvlState = QTE.lvlSetup + q.currentLevel = q.levelFinish or QTE.levelFinish + else + R.addPoints(q, q.SCORELEVEL) + q.iBonus = q.iBonus + q.SCORELEVEL + q.bSkipIntroClip = false + q.iLiveSave, q.iScoreSave = q.iLives, q.iScore + q.bAllowSave = true + Q.sound(q, q.sndclear) + q.lvlState = QTE.lvlSetup + q.currentLevel = q.levelFinish or QTE.levelFinish + end + elseif not q.segment[thisLevel][q.iSegPointer][q.SEGMENTCOMPLETE] then + if q.iSegPointer > 0 then + q.iSegPointer = q.iSegPointer - 1 + end + else + q.iLiveSave, q.iScoreSave = q.iLives, q.iScore + q.bAllowSave = true + end + q.bShowLives, q.bShowLvl, q.bShowScene, q.bShowAction, q.bTestMash, q.iMash = false, false, false, false, false, 0 + end + end +end + + +-- A choice: the options walked with the stick and taken with the button, the right one a +-- move made, a wrong one the death it names (the later copies' CHOOSE kind). +function R.doChoose(q) + local m = q.move[q.currentMove] + local numChoice = m[q.moveDeath] + + if q.altState == QTE.lvlSetup then + q.altState = QTE.lvlRunning + q.iChoice = 1 + q.bIgnoreJoy = false + q.bShowChoices = true + -- The ancestor shuffles the options on their first drawing, the same frame, from a + -- reseeded stream. + if q.bShuffleOrder then + q.bShuffleOrder = false + singeRandomize() + if numChoice == 2 then + q.optorder = ({ { 1, 2 }, { 2, 1 } })[math.random(2)] + elseif numChoice == 3 then + q.optorder = ({ { 1, 2, 3 }, { 2, 3, 1 }, { 3, 1, 2 }, { 1, 3, 2 }, { 2, 1, 3 }, { 3, 2, 1 } })[math.random(6)] + elseif numChoice == 4 then + q.optorder = ({ { 1, 2, 3, 4 }, { 4, 2, 3, 1 }, { 3, 4, 1, 2 }, { 1, 3, 2, 4 }, { 2, 1, 4, 3 }, { 4, 3, 2, 1 } })[math.random(6)] + end + end + elseif q.altState == QTE.lvlRunning then + local function take() + q.bShowChoices = false + if q.choice[q.optorder[q.iChoice]][2] == true then + Q.sound(q, rightSound(q)) + q.lvlState = QTE.lvlPlayRest + else + Q.sound(q, q.sndwrong) + m[q.moveDeath] = q.choice[q.optorder[q.iChoice]][3] + q.setupDeathClip(q.thisMove) + q.lvlState = QTE.lvlPlayDeath + end + end + + if q.currentFrame > m[q.inputFrmEnd] then + take() + elseif (q.currentFrame >= m[q.inputFrmStart]) and (q.currentFrame <= m[q.inputFrmEnd]) then + local thisMove = q.NOMOVE + + if q.bIgnoreJoy then + if Q.timerDue(q) then + q.bIgnoreJoy = false + end + else + thisMove = R.scanInput(q) + end + if thisMove == q.UP then + if q.iChoice > 1 then + q.iChoice = q.iChoice - 1 + Q.sound(q, q.sndcoin) + end + elseif thisMove == q.DOWN then + if q.iChoice < numChoice then + q.iChoice = q.iChoice + 1 + Q.sound(q, q.sndcoin) + end + elseif thisMove == q.BUTTON1 then + if q.choice[q.optorder[q.iChoice]][2] == true then + discSkipToFrame(m[q.inputFrmEnd]) + end + take() + end + end + end +end + + +-- ----- The screens --------------------------------------------------------------------------- + +function R.startGame(q) + q.initStages() + if q.bExtendedPlay then + q.currentLevel = QTE.levelNormal + q.iCurPos = q.levelExt + else + if q.iCredits > 0 then + q.iCredits = q.iCredits - 1 + end + q.iScore, q.iScoreTemp, q.iBonus = 0, 0, 0 + if q.currentLevel == QTE.levelContinue then + q.currentLevel = q.iTempLevel + if q.dip_Rewind == 1 then + q.currentMove, q.bSave = q.currentMove + 1, true + elseif q.dip_Rewind == 2 then + q.currentMove = (q.currentMove == q.totalMoves) and (q.currentMove - 1) or (q.currentMove + 1) + q.bSave = true + elseif q.dip_Rewind == 3 then + q.iSegPointer = 0 + end + elseif q.later then + -- The later copies order the levels by a game type: in sequence, at random, by + -- tiers, from the map, or from the dips' level and segment. + q.iContinues, q.iSegPointer = 0, 0 + if q.dip_GameType == 0 then + q.doMixSEQ() + q.iCurPos, q.currentLevel = q.LvlOrder[1], QTE.levelNormal + elseif q.dip_GameType == 1 then + q.doMixRND() + q.iCurPos, q.currentLevel = q.LvlOrder[1], QTE.levelNormal + elseif q.dip_GameType == 2 then + q.doMixTIE() + q.iCurPos, q.currentLevel = q.LvlOrder[1], QTE.levelNormal + elseif q.dip_GameType == 4 then + q.iCurPos = q.dip_StartLevel + q.iSegPointer = q.dip_StartSegment - 1 + q.currentLevel = QTE.levelNormal + elseif q.dip_GameType == 3 then + q.iCurPos, q.currentLevel = q.PlayOrder[1], q.levelMenuScreen + end + else + q.iContinues, q.iSegPointer = 0, 0 + q.iCurPos = q.dip_StartLevel + q.iSegPointer = q.dip_StartSegment - 1 + -- The ancestor draws from its random stream here (a seed it never uses). + q.rndegg = math.random(math.floor(os.clock() * 100000)) + if q.bAllowMap and (q.MapStart == 0) then + q.currentLevel = q.levelMenuScreen + else + q.currentLevel = QTE.levelNormal + end + end + end + q.lvlState = QTE.lvlSetup + q.bShowLives, q.bShowScore = true, not q.later + q.iLives = q.dip_LivesPerCredit + q.bShowAction, q.bTestMash, q.iMash = false, false, 0 + q.bShowCredits, q.bShowLCD, q.bResetContinue, q.bExtendedPlay = false, false, false, false +end + + +function R.doIntro(q) + -- The first copies quit on start with coin; the secret's combination ends in DOWN there + -- and RIGHT in the later ones. + local fourth = q.later and "p1RIGHT" or "p1DOWN" + + local function quitting() + if (not q.later) and q.p1START1 and q.p1COIN1 then + singeQuit() + return true + end + return false + end + local function secret() + if q.p1BUTTON2 and q.p1BUTTON3 and q.p1UP and q[fourth] and q.bAllowSecret then + q.p1BUTTON2, q.p1BUTTON3, q.p1UP, q[fourth] = false, false, false, false + q.bExtendedPlay = true + R.startGame(q) + q.bShowCredits = false + return true + end + return false + end + + if q.lvlState == QTE.lvlSetup then + Q.setupClip(q, q.offsetIntro01, q.offsetIntro01end) + 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 + q.p1BUTTON1 = false + discSkipToFrame(q.frameCommands) + discPause() + q.bShowLCD = false + if q.later then + q.bShowCredits = false + end + Q.timerON(q, q.tweaks.introHold or (q.later and 12 or 5)) + q.lvlState = QTE.branch02 + else + quitting() + end + elseif q.lvlState == QTE.branch02 then + if Q.timerDue(q) then + discSkipToFrame(q.frameRankings) + Q.timerON(q, q.tweaks.rankingsHold or (q.later and 8 or 5)) + discPause() + q.lvlState = QTE.branch03 + elseif not secret() then + quitting() + end + elseif (q.lvlState == QTE.branch03) and q.later then + -- The later copies show a filler clip or still after the rankings. + if Q.timerDue(q) then + q.bShowLCD, q.bShowCredits = true, true + q.doFillerFrame() + q.lvlState = QTE.branch04 + end + elseif q.lvlState == QTE.branch03 then + if Q.timerDue(q) then + q.lvlState = QTE.lvlSetup + else + quitting() + end + elseif q.lvlState == QTE.branch04 then + if clipEnded(q) then + q.lvlState = QTE.lvlSetup + elseif q.later and q.p1BUTTON1 then + q.p1BUTTON1 = false + q.lvlState = QTE.branch01 + end + elseif q.lvlState == QTE.branch05 then + if Q.timerDue(q) then + q.gameflow = "init" + elseif not quitting() and q.p1START1 then + q.p1START1 = false + if ((q.iCredits > 0) or (q.dip_CoinsPerCredit == q.DOPT_FREEPLAY)) and not q.dip_Movie then + R.startGame(q) + q.bShowCredits = false + elseif q.dip_Movie then + q.lvlState = QTE.lvlSetup + q.currentLevel = QTE.levelMovie + q.bShowCredits = false + end + end + end + if (q.dip_CoinsPerCredit == q.DOPT_FREEPLAY) or (q.bShowCredits and (q.iCredits > 0)) then + if q.p1START1 and not q.dip_Movie then + q.p1START1, q.bShowCredits = false, false + R.startGame(q) + elseif q.p1START1 and q.dip_Movie then + q.p1START1, q.bShowCredits = false, false + q.lvlState = QTE.lvlSetup + q.currentLevel = QTE.levelMovie + end + end +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. + local step = q.later and { QTE.branch01, QTE.branch02, QTE.branch03, QTE.branch04, QTE.branch05 } or { QTE.branch02, QTE.branch03, QTE.branch04, QTE.branch05, QTE.branch06 } + + if q.later then + q.bShowScore, q.bRes = false, true + end + if q.lvlState == QTE.lvlSetup then + Q.setupClip(q, q.offsetClear, q.offsetClearend) + q.lvlState = step[1] + elseif q.lvlState == step[1] then + if clipEnded(q) then + Q.timerON(q, 1) + discPause() + q.lvlState = step[2] + end + elseif q.lvlState == step[2] then + if Q.timerDue(q) then + Q.timerON(q, 2) + discPause() + Q.sound(q, q.sndcredit) + q.lvlState = step[3] + end + elseif q.lvlState == step[3] then + if Q.timerDue(q) then + Q.timerON(q, 2) + discPause() + Q.sound(q, q.sndcredit) + q.lvlState = step[4] + end + elseif q.lvlState == step[4] then + if Q.timerDue(q) then + Q.timerON(q, 1) + discPause() + Q.sound(q, q.sndvictory) + q.lvlState = step[5] + end + elseif q.lvlState == step[5] then + if Q.timerDue(q) then + q.iScoreTemp, q.iBonus = 0, 0 + if ((q.later and (q.dip_GameType == 3)) or ((not q.later) and q.bAllowMap)) and (q.MapStart == 0) then + q.lvlState = QTE.lvlSetup + q.currentLevel = q.levelMenuScreen + else + q.NextLevel(q.iCurPos) + q.lvlState = QTE.lvlSetup + q.currentLevel = QTE.levelNormal + end + end + end +end + + +-- The high score board: the clips, the name left unentered (what is typed is not play), the +-- board updated, and on to the game over. +function R.doHighScore(q) + if q.lvlState == QTE.lvlSetup then + q.bShowLives, q.bShowScore, q.bShowCredits, q.bShowAction, q.bShowLCD, q.bIgnoreJoy = false, false, false, false, false, false + q.sName = "" + 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 + Q.setupClip(q, q.offsetEnterHScore, q.offsetEnterHScoreend) + q.lvlState = QTE.lvlRunning + end + elseif q.lvlState == QTE.branch02 then + if Q.timerDue(q) then + q.lvlState = QTE.lvlEnd + end + 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. + q.lvlState = QTE.lvlEnd + else + discPause() + Q.timerON(q, 2) + q.lvlState = QTE.branch04 + end + end + elseif Q.timerDue(q) then + q.lvlState = QTE.lvlEnd + end + elseif q.lvlState == QTE.lvlRunning then + if q.currentFrame == q.iFrameEnd then + q.updateHS(q.sName, q.iScore) + discPause() + Q.timerON(q, 0.5) + q.lvlState = QTE.branch02 + end + elseif q.lvlState == QTE.lvlEnd then + q.lvlState = QTE.lvlSetup + q.currentLevel = QTE.levelGameOver + end +end + + +-- The movie player: the levels' clips in turn, START back to the attract loop. +function R.startMovie(q) + if q.lvlState == QTE.lvlSetup then + q.iScore, q.iScoreTemp, q.iBonus, q.iContinues, q.iSegPointer, q.iMovie = 0, 0, 0, 0, 0, 1 + Q.setupClip(q, q.offsetLevel01, q.offsetLevel02 - 2) + q.bShowLives, q.bShowAction, q.bShowScore, q.bShowLvl, q.bShowCredits, q.bShowLCD = false, false, false, false, false, false + q.lvlState = QTE.lvlRunning + elseif q.lvlState == QTE.lvlRunning then + local function clipOf(index) + local from = q["offsetLevel" .. string.format("%02d", index)] + local to = (index < q.finalstage) and (q["offsetLevel" .. string.format("%02d", index + 1)] - 2) or (q.offsetMenus - 2) + + Q.setupClip(q, from, to) + end + + if q.currentFrame == q.iFrameEnd then + q.p1RIGHT = true + elseif q.p1START1 then + q.p1START1 = false + q.lvlState = QTE.lvlSetup + q.currentLevel = QTE.levelIntro + elseif q.p1RIGHT then + q.p1RIGHT = false + q.iMovie = (q.iMovie < q.finalstage) and (q.iMovie + 1) or 1 + clipOf(q.iMovie) + elseif q.p1LEFT then + q.p1LEFT = false + q.iMovie = (q.iMovie > 1) and (q.iMovie - 1) or q.finalstage + clipOf(q.iMovie) + end + elseif q.lvlState == QTE.lvlEnd then + q.lvlState = QTE.lvlSetup + q.currentLevel = QTE.levelIntro + end +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.", + params = {}, + attach = function(instance, params) + local data = AUTHOR_GAME.qte or {} + local q = { instance = instance, snapshot = data, rdg = true, namedSounds = true, loadsFiles = true } + + instance.q = q + instance.hearsAll = true + for key, value in pairs(QTE) do + if (key ~= "FLAG_OF") and (key ~= "BRANCH") then + q[key] = value + end + end + q.env = Q.shim(q) + q.MYDIR = (AUTHOR_SOURCE_DIR or AUTHOR_DIR or ""):gsub("[/\\]$", "") + q.sounds = {} + -- The game's script loads its globals, its levels, its map, its board, and its + -- helpers through the shim: they declare the constants, the tables, and the + -- functions the loop calls. + if data.script then + Q.runScript(q, data.script) + end + -- The port keeps no saves and reads no config: the game's own file functions (the + -- later copies autosave in setupLevel) do nothing here. + for _, name in ipairs({ "autoSave", "readSave", "writeSave", "readConfig", "writeConfig" }) do + q[name] = function() end + end + -- The map screen (the game's own doLevelSelect) draws from the sprite table its + -- initJob fills; the port draws its own, so the table is there and empty. + q.sprite = q.sprite or {} + for key, value in pairs(data.dips or {}) do + q[key] = value + end + for _, name in ipairs({ "dip_AllowContinue", "dip_ShowAction", "dip_Kidmode", "dip_Movie", "dip_Debug" }) do + q[name] = (q[name] == 1) or (q[name] == true) + 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 + -- some states differently, test clip ends with "at or past", and add a game type. + q.later = (q.LvlOrder ~= nil) + if q.later then + q.iPenal = ({ [0] = 0, [1] = q.PenalNorm, [2] = q.PenalHard })[q.dip_Difficulty] or 0 + end + -- The pointer in the game's coordinates, as the ancestor's onMouseMoved keeps it. + q.mouseX, q.mouseY = q.mouseX or 0, q.mouseY or 0 + q.highscore = {} + for k, entry in ipairs(data.highScores or {}) do + q.highscore[k] = { entry[1], entry[2] } + end + q.levelMap = {} + for k = 1, 16 do + q.levelMap[k] = false + end + q.iCoins, q.iCredits, q.iScore, q.iScoreTemp, q.iBonus, q.iSegPointer, q.iMash, q.iContinues = 0, 0, 0, 0, 0, 0, 0, 0 + q.iLives, q.iCurPos, q.currentMove, q.iPauseFrame, q.iTempLevel, q.iMovie, q.totalMoves = 0, 1, 0, 0, 0, 1, 0 + q.move, q.stage, q.segment = q.move or {}, q.stage or {}, q.segment or {} + q.thisMove, q.currentFrame, q.iFrameStart, q.iFrameEnd, q.myButton = q.NOMOVE, 0, 0, 0, 0 + q.bPause, q.bAct, q.bTestMash, q.bSave, q.bSkipIntroClip, q.bGOAlt, q.bExtendedPlay, q.bAllowSave, q.bCheckMove = false, false, false, false, false, false, false, false, false + q.bShowCredits, q.bShowScore, q.bShowLives, q.bShowAction, q.bShowLvl, q.bShowScene, q.bShowSkip, q.bShowLCD = true, false, false, false, false, false, false, true + q.gameflow = "vldp" + q.currentLevel = QTE.levelIntro + q.lvlState = QTE.lvlSetup + Q.clearInput(q) + q.p1START1, q.p1START2, q.p1COIN1, q.p1COIN2, q.p1SERVICE = false, false, false, false, false + if q.MovieFPS then + discSetFPS(q.MovieFPS) + end + end, + step = function(instance) + local q = instance.q + + q.currentFrame = discGetFrame() + if q.gameflow == "vldp" then + -- The ancestor's start: the title clip with the inputs paused, then the init. + if q.later then + q.saveOffset() -- The later copies copy their offsets under short names here. + end + 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 clipEnded(q) then + discPause() + q.lvlState = QTE.lvlEnd + end + elseif q.lvlState == QTE.lvlEnd then + q.bPause = false + q.gameflow = "init" + q.lvlState = QTE.lvlSetup + end + return + end + if q.gameflow == "init" then + q.gameflow = "running" + q.currentLevel = QTE.levelIntro + q.lvlState = QTE.lvlSetup + q.iCoins, q.iScore, q.iScoreTemp, q.iBonus, q.iSegPointer = 0, 0, 0, 0, 0 + q.rndegg = math.random(math.floor(os.clock() * 100000)) + for k = 1, 16 do + q.levelMap[k] = false + end + q.bShowCredits = true + q.bShowScore, q.bShowLives, q.bShowAction, q.bAct, q.bTestMash, q.iMash = false, false, false, false, false, 0 + q.bShowLvl, q.bShowScene, q.bShowSkip = false, false, false + return + end + local level = q.currentLevel + + if level == QTE.levelIntro then + R.doIntro(q) + elseif level == QTE.levelNormal then + R.doLevel(q) + elseif level == q.levelMenuScreen then + q.doLevelSelect() + elseif level == QTE.levelContinue then + R.doContinue(q) + elseif level == QTE.levelGameOver then + R.doGameOver(q) + elseif level == QTE.levelHighScore then + R.doHighScore(q) + elseif level == QTE.levelMovie then + R.startMovie(q) + elseif level == (q.levelFinish or QTE.levelFinish) then + R.doFinish(q) + elseif (level == QTE.levelService) or (level == QTE.levelSave) then + -- The service and save menus 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.iCurPos + if q.bShowAction and q.move[q.currentMove] then + AUTHOR_VARS.prompt = q.move[q.currentMove][q.correctMove] + else + AUTHOR_VARS.prompt = "" + end + end, + on = function(instance, name, event) + local q = instance.q + local switch = event.switch or -1 + local flag = qteFlagOf(switch) + + if name == "pointer" then + if q.ratiox then + q.mouseX = event.x * q.ratiox - q.ratioxOffset + q.mouseY = event.y * q.ratioy - q.ratioyOffset + end + return + end + if name == "pressed" then + q.myButton = switch + if q.bPause or (flag == nil) then + return + end + if flag == "p1COIN1" then + q.p1COIN1 = true + if q.p1START1 then + q.p1START1 = false + singeQuit() + end + elseif flag == "p1START1" then + q.p1START1 = true + if q.p1COIN1 then + q.p1COIN1 = false + singeQuit() + end + else + q[flag] = true + if (flag == "p1BUTTON1") and q.bTestMash then + q.iMash = q.iMash + 1 + end + end + elseif name == "released" then + q.myButton = 0 + if switch == SWITCH_PAUSE then + q.bPause = not q.bPause + return + end + if (q.gameflow ~= "running") or q.bPause or (flag == nil) then + return + end + if (flag == "p1COIN1") or (flag == "p1COIN2") then + if (q.currentLevel ~= QTE.levelService) and (q.currentLevel ~= QTE.levelNormal) then + q.p1COIN1, q.p1COIN2 = false, false + if (q.dip_CoinsPerCredit ~= q.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 + q.iCredits = q.iCredits + 1 + Q.sound(q, q.sndcredit) + else + Q.sound(q, q.sndcoin) + end + if q.currentLevel == QTE.levelContinue then + q.bResetContinue = true + end + end + end + elseif (flag ~= "p1SERVICE") then + q[flag] = false + end + end + end +} + + +-- 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 +-- 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.reload the folder of the crosshair pictures, for an edition that swaps them +function authorDrawGun(q, opts) + local env = q.env + + if not (q.bShowMouse and (not (opts.cannon and q.bCannonFired)) and singeWantsCrosshairs()) then + return + end + env.spriteDraw(q.cursorx, q.cursory, q.sprCursor) + if q.bReversePointer and not (opts.highScore and (q.currentLevel == q.levelHighScore)) then + q.iRevFrames = q.iRevFrames + 1 + if q.iRevFrames == q.REV_DELAY then + q.bReversePointer = false + if opts.reload then + local name = ALG_CROSSHAIRS[q.dip_Crosshair] + + if name then + q.sprCursor = env.spriteLoad(opts.reload .. name) + end + end + else + if opts.reload then + q.sprCursor = env.spriteLoad(opts.reload .. "crosshaire.png") + end + env.spriteDraw(q.revsetx, q.revsety, q.sprRev) + end + end +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 +-- 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.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) +function authorDrawGuns(q, opts) + local env = q.env + + for player = 1, 2 do + local s = (player == 2) and "2" or "" + + if q["bShowMouse" .. s] and not (opts.cannon and q.bCannonFired) then + if singeWantsCrosshairs() then + env.spriteDraw(q["cursor" .. player .. "x"], q["cursor" .. player .. "y"], q["sprCursor" .. s]) + end + if q["bReversePointer" .. s] and not (opts.highScore and ((q.currentLevel == q.levelHighScore) or (q.currentLevel == q.levelHighScore2))) then + if q["iRevFrames" .. s] == 0 then + q["freeze" .. player .. "x"], q["freeze" .. player .. "y"] = q["revset" .. player .. "x"], q["revset" .. player .. "y"] + end + q["iRevFrames" .. s] = q["iRevFrames" .. s] + 1 + if q["iRevFrames" .. s] <= q.sprRevFrames * q.sprRevFrameReps then + env.spriteDrawFrame(q["freeze" .. player .. "x"], q["freeze" .. player .. "y"], q["iRevFrames" .. s] / q.sprRevFrameReps, q["sprRev" .. s]) + else + if opts.resets then + q["iRevFrames" .. s] = 0 + end + q["bReversePointer" .. s] = false + end + if opts.reload then + local name = ALG_CROSSHAIRS[q.dip_Crosshair] + + if name then + q["sprCursor" .. s] = env.spriteLoad(opts.reload .. ((player == 2) and name:gsub("%.png$", "p2.png") or name)) + end + end + end + if q["bGunAnim" .. s] then + q["iHandFrames" .. s] = q["iHandFrames" .. s] + 1 + if q["iHandFrames" .. s] <= q.sprHandFrames * q.sprHandFrameReps then + env.spriteDrawFrame(q["cursor" .. player .. "x"], q["cursor" .. player .. "y"], math.ceil(q["iHandFrames" .. s] / q.sprHandFrameReps), q["sprHand" .. s]) + else + if opts.resets then + q["iHandFrames" .. s] = 0 + end + q["bGunAnim" .. s] = false + end + end + end + end +end + + +function authorFlushDraws(q) + Q.flushDraws(q) + fontSelect(AUTHOR_FONT) +end + + +function authorDropDraws(q) + Q.dropDraws(q) +end + + +function authorConfigLoad(q) + Q.configLoad(q) +end + + +function authorConfigSave(q) + Q.configSave(q) +end + + +function authorShim(q) + return Q.shim(q) +end + + +function authorRunScript(q, name) + return Q.runScript(q, name) +end + + +-- ===== The Time Gal (Singe Edition) game loop ================================================ +-- +-- RDG2010's HD remake of the Singe 1 Time Gal 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 +-- level by tiers, at random, or in sequence. As with the map-mode loop, the game's own files +-- are loaded through the shim and its data and order functions (setupLevel, chooseLevel, the +-- doMixLevels three, fetchIntroVideo, doFillerFrame, the board) run as they are; the loop is +-- this, under the game's own names and numbers. + +local T = {} + + +-- The ancestor's setupClip: a skip only when the disc is not already a frame short of the +-- clip, and the disc let go if it was paused there. +function T.setupClip(q, from, to) + q.iFrameStart, q.iFrameEnd = from, to + if q.currentFrame + 1 ~= from then + discSkipToFrame(from) + discChangeSpeed(1, 1) + elseif discGetState() == q.VLDP_PAUSED then + discPlay() + end +end + + +-- The inputs read and let go; any input holds the stick off for a fifth of a second. +function T.scanInput(q) + local result = q.NOMOVE + + if q.p1UP then result = q.UP + elseif q.p1DOWN then result = q.DOWN + elseif q.p1LEFT then result = q.LEFT + elseif q.p1RIGHT then result = q.RIGHT + elseif q.p1BUTTON1 then result = q.ACTION end + if q.p1UP or q.p1DOWN or q.p1LEFT or q.p1RIGHT or q.p1BUTTON1 then + q.bIgnoreJoy = true + Q.timerON(q, 0.2) + end + q.p1UP, q.p1DOWN, q.p1LEFT, q.p1RIGHT, q.p1BUTTON1 = false, false, false, false, false + + return result +end + + +-- Points, and a life at every quota. +function T.addPoints(q, amount) + q.iScore = q.iScore + amount + q.i1up = q.i1up + amount + if q.i1up >= q.iQuota then + q.i1up = q.i1up - q.iQuota + q.iQuota = 300000 + q.iLives = q.iLives + 1 + Q.sound(q, q.snd1up) + if q.i1up < 0 then + q.i1up = 0 + end + end +end + + +function T.startGame(q) + q.iCredits = q.iCredits - 1 + q.iLives = q.dip_LivesPerCredit + q.iScore, q.i1up, q.iQuota = 0, 0, 200000 + q.rndegg = random.new(math.floor(os.clock() * 100000)) + -- The ancestor renders the top score to a sprite here for its attract screen. + q.finalShuffle = { false, false, false } + if q.currentLevel == q.levelContinue then + q.currentLevel = q.iTempLevel + q.bSection01Reached, q.bSection02Reached, q.bSection03Reached = false, false, false + else + q.iContinues = 0 + q.rndegg = random.new(math.floor(os.clock() * 100000)) + q.currentLevel = q.levelChoose + q.bLevelInit = false + if q.dip_SortStyle == q.DOPT_RANDOM then + q.iStageIndex = 0 + q.doMixLevelsRandom() + elseif q.dip_SortStyle == q.DOPT_DL then + q.iStageIndex = 1 + q.doMixLevels() + elseif q.dip_SortStyle == q.DOPT_SEQUENTIAL then + q.iStageIndex = 0 + q.doMixLevelSequential() + end + end + q.lvlState = q.lvlSetup + q.bShowLives, q.bShowScore, q.bShowCredits, q.bResetContinue = true, true, false, false + q.bShowResurrect, q.bShowTimeChoices = true, false + q.resetChannels() +end + + +-- The attract loop: an intro clip, a filler, the credits clip, the rankings still, and round; +-- a coin brings the "press start" still for half a minute. +function T.doIntro(q) + if q.lvlState == q.lvlSetup then + q.fetchIntroVideo() + q.lvlState = q.branch01 + q.resetChannels() + q.bMuteAttract = false + q.bShowCredits, q.bShowLives, q.bCheckForCredits = true, false, true + elseif q.lvlState == q.branch01 then + if (q.currentFrame == q.iFrameEnd) or q.p1BUTTON1 then + q.p1BUTTON1 = false + q.doFillerFrame() + Q.timerON(q, 6) + q.bMuteAttract = not q.bMuteAttract + q.lvlState = q.branch02 + end + elseif q.lvlState == q.branch02 then + if Q.timerDue(q) then + T.setupClip(q, 60400, 60680) + q.lvlState = q.branch03 + q.bShowCredits = false + end + elseif q.lvlState == q.branch03 then + if q.currentFrame == q.iFrameEnd then + discSkipToFrame(59496) + discPause() + Q.timerON(q, 12) + q.lvlState = q.branch04 + end + elseif q.lvlState == q.branch04 then + if Q.timerDue(q) then + q.fetchIntroVideo() + q.lvlState = q.branch01 + q.bShowCredits = true + if q.bMuteAttract then + q.muteAudio() + else + q.resetChannels() + end + end + elseif q.lvlState == q.branch05 then + if Q.timerDue(q) then + q.gameflow = "init" + elseif q.p1START1 then + q.p1START1 = false + if (q.iCredits > 0) or (q.dip_CoinsPerCredit == q.DOPT_FREEPLAY) then + T.startGame(q) + q.bShowCredits = false + end + end + end + if q.dip_CoinsPerCredit == q.DOPT_FREEPLAY then + if q.p1START1 then + q.p1START1 = false + T.startGame(q) + end + elseif q.bCheckForCredits and (q.iCredits > 0) then + q.bCheckForCredits = false + q.bShowCredits = true + discSkipToFrame(59500) + discPause() + Q.timerON(q, 30) + q.lvlState = q.branch05 + end +end + + +-- A time-stop: three options walked with the stick and taken with the action button, the +-- right one a move made, a wrong one its own death clip. The ancestor shuffles the options on +-- their first drawing, the same frame. +function T.doTimeStop(q) + local m = q.move[q.currentMove] + + if q.altState == q.lvlSetup then + q.altState = q.lvlRunning + q.iChoice = 1 + q.bIgnoreJoy = false + q.bShowTimeChoices = true + if q.bShuffleOrder then + q.bShuffleOrder = false + q.iShuffle = math.modf(math.random(1000, 6999) / 1000) + q.optorder = ({ { 1, 2, 3 }, { 2, 3, 1 }, { 3, 1, 2 }, { 1, 3, 2 }, { 2, 1, 3 }, { 3, 2, 1 } })[q.iShuffle] + end + elseif q.altState == q.lvlRunning then + local picked = q.opt[q.optorder[q.iChoice]] + + if q.currentFrame > m[q.inputFrmEnd] then + if picked[2] == true then + q.lvlState = q.lvlPlayRest + Q.sound(q, q.sndteedo) + else + Q.sound(q, q.sndwrong) + T.setupClip(q, picked[3], picked[4]) + q.lvlState = q.lvlPlayDeath + end + q.bShowTimeChoices = false + elseif (q.currentFrame >= m[q.inputFrmStart]) and (q.currentFrame <= m[q.inputFrmEnd]) then + local thisMove = q.NOMOVE + + if q.bIgnoreJoy then + if Q.timerDue(q) then + q.bIgnoreJoy = false + end + else + thisMove = T.scanInput(q) + end + if thisMove == q.UP then + if q.iChoice > 1 then + q.iChoice = q.iChoice - 1 + Q.sound(q, q.sndsel) + end + elseif thisMove == q.DOWN then + if q.iChoice < 3 then + q.iChoice = q.iChoice + 1 + Q.sound(q, q.sndsel) + end + elseif thisMove == q.ACTION then + if picked[2] == true then + Q.sound(q, q.sndteedo) + discSkipToFrame(m[q.inputFrmEnd]) + q.lvlState = q.lvlPlayRest + else + Q.sound(q, q.sndwrong) + T.setupClip(q, picked[3], picked[4]) + q.lvlState = q.lvlPlayDeath + end + q.bShowTimeChoices = false + end + end + end +end + + +-- A level: its moves from the checkpoint reached, a death clip and a life for a wrong or +-- missed one, a time-stop where the move says so, a tally at the end. +function T.doLevel(q) + local thisLevel = q.stage[q.iStageIndex][q.THISLEVEL] + + if q.lvlState == q.lvlSetup then + q.lvlState = q.branch02 + q.bShuffleOrder = true + q.bLevelInit = true + q.setupLevel(thisLevel, q.dip_Difficulty) + if q.bShowResurrect then + T.setupClip(q, 59602, 59746) + q.lvlState = q.branch01 + q.bShowResurrect = false + end + q.bSpecialCase, q.bPlayPrompt = false, true + elseif q.lvlState == q.branch01 then + if q.currentFrame == q.iFrameEnd then + q.lvlState = q.branch02 + end + elseif q.lvlState == q.branch02 then + if q.checkpoint == nil then + q.currentMove = 1 + else + local section = 1 + + if q.bSection01Reached and not q.bSection02Reached then + section = 2 + elseif q.bSection02Reached and not q.bSection03Reached then + section = 3 + elseif q.bSection03Reached then + section = 4 + end + q.currentMove = (section > 1) and q.checkpoint[section] or 1 + end + if (q.iDeathCount == 0) and (q.currentMove == 1) then + discSkipToFrame(q.iLevelStart) + else + discSkipToFrame(q.move[q.currentMove][q.moveFrmStart]) + end + q.lvlState = q.lvlRunning + elseif q.lvlState == q.branch03 then + T.doTimeStop(q) + elseif q.lvlState == q.lvlShowTally then + if q.currentFrame == q.iLevelEnd then + Q.timerON(q, 1) + if thisLevel == q.finalstage then + discSkipToFrame(3) + q.lvlState = q.branch08 + else + q.bLevelComplete = true + q.lvlState = q.branch05 + end + discPause() + end + elseif q.lvlState == q.lvlPlayRest then + if q.currentFrame == q.move[q.currentMove][q.moveFrmEnd] then + q.currentMove = q.currentMove + 1 + if q.currentMove <= q.totalMoves then + if q.checkpoint ~= nil then + for k = 2, q.checkpoint[q.checkpointmax] do + if q.currentMove == q.checkpoint[k] then + if k - 1 == 1 then + q.bSection01Reached = true + elseif k - 1 == 2 then + q.bSection02Reached = true + elseif k - 1 == 3 then + q.bSection03Reached = true + end + break + end + end + end + q.bShowAction, q.bPlayPrompt = false, true + T.setupClip(q, q.move[q.currentMove][q.moveFrmStart], q.move[q.currentMove][q.moveFrmEnd]) + if q.move[q.currentMove][q.correctMove] == q.TIMESTOP then + q.altState = q.lvlSetup + q.lvlState = q.branch03 + else + q.lvlState = q.lvlRunning + end + else + q.iBonus = (q.iDeathCount >= 4) and 0 or ((4 - q.iDeathCount) * 10000) + if thisLevel == q.finalstage then + q.bMuteAttract = false + q.bShowCredits, q.bShowLives, q.bShowScore, q.bCheckForCredits = false, false, false, false + q.iBonus = q.iBonus + 40000 + end + T.addPoints(q, q.iBonus) + q.lvlState = q.lvlShowTally + end + end + elseif q.lvlState == q.lvlRunning then + local m = q.move[q.currentMove] + + if q.currentFrame > m[q.inputFrmEnd] then + Q.sound(q, q.sndwrong) + q.bShowAction = false + T.setupClip(q, m[q.deathFrmStart], m[q.deathFrmEnd]) + q.lvlState = q.lvlPlayDeath + elseif (q.currentFrame >= m[q.inputFrmStart]) and (q.currentFrame <= m[q.inputFrmEnd]) then + local thisMove = T.scanInput(q) + + if q.dip_showAction then + q.bShowAction = true + end + q.bPlayPrompt = false + if thisMove ~= q.NOMOVE then + q.bShowAction = false + if thisMove == m[q.correctMove] then + T.addPoints(q, q.POINTS_MOVE) + Q.sound(q, q.sndright) + q.lvlState = q.lvlPlayRest + else + Q.sound(q, q.sndwrong) + T.setupClip(q, m[q.deathFrmStart], m[q.deathFrmEnd]) + q.lvlState = q.lvlPlayDeath + end + end + else + T.scanInput(q) + end + elseif q.lvlState == q.lvlPlayDeath then + if q.currentFrame == q.iFrameEnd then + q.iLives = q.iLives - 1 + q.iDeathCount = q.iDeathCount + 1 + if q.iLives > 0 then + T.setupClip(q, 59752, 59825) + q.lvlState = q.branch06 + elseif q.iLives == 0 then + T.setupClip(q, 59828, 59885) + q.bShowLives, q.bShowScore, q.bShowAction = false, false, false + q.lvlState = q.branch07 + end + end + elseif q.lvlState == q.branch04 then + if Q.timerDue(q) then + q.lvlState = q.lvlEnd + end + elseif q.lvlState == q.branch05 then + if Q.timerDue(q) then + q.lvlState = q.lvlEnd + end + elseif q.lvlState == q.branch06 then + if q.currentFrame == q.iFrameEnd then + discPause() + Q.timerON(q, 4) + q.lvlState = q.branch04 + end + elseif q.lvlState == q.branch07 then + if q.currentFrame == q.iFrameEnd then + q.lvlState = q.lvlEnd + end + elseif q.lvlState == q.branch08 then + if Q.timerDue(q) then + if q.dip_IntroType == q.DOPT_ORIGINAL then + T.setupClip(q, 58630, 59320) + elseif q.dip_IntroType == q.DOPT_REMIX then + T.setupClip(q, 62120, 65203) + elseif math.random(1, 1000) > 500 then + T.setupClip(q, 62120, 65203) + else + T.setupClip(q, 58630, 59320) + end + q.bLevelComplete = true + q.lvlState = q.branch07 + end + elseif q.lvlState == q.lvlEnd then + q.lvlState = q.lvlSetup + if q.iLives == 0 then + if q.dip_AllowContinue and ((q.iContinues < q.dip_LimitContinue) or (q.dip_LimitContinue == q.DOPT_INFINITE_CONTINUES)) then + q.iTempLevel = q.currentLevel + q.currentLevel = q.levelContinue + q.iContinues = q.iContinues + 1 + else + q.currentLevel = q.newScore(q.iScore) and q.levelHighScore or q.levelGameOver + end + elseif q.bLevelComplete then + q.stage[q.iStageIndex][q.BEATSTATUS] = true + q.currentLevel = q.levelChoose + elseif not q.dip_MustBeatLevel then + q.currentLevel = q.levelChoose + end + end +end + + +function T.doContinue(q) + if q.lvlState == q.lvlSetup then + T.setupClip(q, 59900, 60390) + q.lvlState = q.lvlRunning + q.bShowLives, q.bShowCredits = false, true + elseif q.lvlState == q.lvlRunning then + if q.bResetContinue then + q.bResetContinue = false + discSkipToFrame(59920) + end + if q.currentFrame == q.iFrameEnd then + q.lvlState = q.lvlEnd + elseif q.p1START1 then + q.p1START1 = false + if (q.iCredits > 0) or (q.dip_CoinsPerCredit == q.DOPT_FREEPLAY) then + T.startGame(q) + q.bShowLives, q.bShowCredits = true, false + end + end + elseif q.lvlState == q.lvlEnd then + q.lvlState = q.lvlSetup + q.currentLevel = q.newScore(q.iScore) and q.levelHighScore or q.levelIntro + end +end + + +function T.doGameOver(q) + if q.lvlState == q.lvlSetup then + q.bShowLives, q.bShowScore, q.bShowCredits = false, false, false + T.setupClip(q, 59320, 59487) + q.lvlState = q.lvlRunning + elseif q.lvlState == q.lvlRunning then + if q.currentFrame == q.iFrameEnd then + q.lvlState = q.lvlEnd + end + elseif q.lvlState == q.lvlEnd then + q.lvlState = q.lvlSetup + q.currentLevel = q.levelIntro + end +end + + +-- The board: the clip, the name left unentered (what is typed is not play; the ancestor gives +-- an empty name one at random), the board updated, and on to the game over. +function T.doHighScore(q) + if q.lvlState == q.lvlSetup then + q.bShowLives, q.bShowScore, q.bShowCredits, q.bShowAction, q.bIgnoreJoy = false, false, false, false, false + q.iCursor, q.sName = 1, "" + q.initArray() + T.setupClip(q, 60745, 62122) + q.lvlState = q.lvlRunning + elseif q.lvlState == q.lvlRunning then + if q.currentFrame == q.iFrameEnd then + discPause() + Q.timerON(q, 0.5) + q.lvlState = q.branch01 + end + elseif q.lvlState == q.branch01 then + if Q.timerDue(q) then + q.lvlState = q.lvlEnd + end + elseif q.lvlState == q.lvlEnd then + q.updateHS(q.sName, q.iScore) + q.lvlState = q.lvlSetup + q.currentLevel = q.levelGameOver + end +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.", + 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 } + + instance.q = q + instance.hearsAll = true + for key, value in pairs(QTE) do + if (key ~= "FLAG_OF") and (key ~= "BRANCH") then + q[key] = value + end + end + q.env = Q.shim(q) + q.MYDIR = (AUTHOR_SOURCE_DIR or AUTHOR_DIR or ""):gsub("[/\\]$", "") + q.sounds = {} + if data.script then + Q.runScript(q, data.script) + end + -- The port keeps no config of its own: the dips and the board come from the description. + for _, name in ipairs({ "readConfig", "writeConfig" }) do + q[name] = function() end + end + for key, value in pairs(data.dips or {}) do + q[key] = value + end + for _, name in ipairs({ "dip_showAction", "dip_AllowContinue", "dip_MustBeatLevel" }) do + q[name] = (q[name] == 1) or (q[name] == true) + end + q.highscore = {} + for k, entry in ipairs(data.highScores or {}) do + q.highscore[k] = { entry[1], entry[2] } + end + q.move, q.stage, q.opt = q.move or {}, q.stage or {}, q.opt or {} + q.iCoins, q.iCredits, q.iScore, q.iLives, q.i1up, q.iQuota, q.iStageIndex, q.iContinues, q.iDeathCount, q.iBonus = 0, 0, 0, 0, 0, 200000, 0, 0, 0, 0 + q.currentMove, q.totalMoves, q.currentFrame, q.iFrameStart, q.iFrameEnd, q.iTempLevel, q.iChoice = 0, 0, 0, 0, 0, 0, 0 + q.bPause, q.bIgnoreJoy, q.bShuffleOrder, q.bShowTimeChoices, q.bShowResurrect, q.bLevelComplete, q.bLevelInit = false, false, false, false, false, false, false + q.bShowCredits, q.bShowScore, q.bShowLives, q.bShowAction, q.bCheckForCredits, q.bResetContinue, q.bMuteAttract = false, false, false, false, false, false, false + q.bSection01Reached, q.bSection02Reached, q.bSection03Reached, q.bSpecialCase, q.bPlayPrompt = false, false, false, false, false + q.gameflow = "vldp" + q.currentLevel = q.levelIntro + q.lvlState = q.lvlSetup + Q.clearInput(q) + q.p1START1, q.p1COIN1, q.p1COIN2, q.p1SERVICE = false, false, false, false + end, + step = function(instance) + local q = instance.q + + q.currentFrame = discGetFrame() + if q.gameflow == "vldp" then + -- The ancestor's start: the title clip, then the init. + if q.lvlState == q.lvlSetup then + discSetFPS(29.97) + T.setupClip(q, 59536, 59598) + discPlay() + q.lvlState = q.lvlRunning + elseif q.lvlState == q.lvlRunning then + if q.currentFrame == q.iFrameEnd then + q.lvlState = q.lvlEnd + end + elseif q.lvlState == q.lvlEnd then + q.gameflow = "init" + q.lvlState = q.lvlSetup + end + return + end + if q.gameflow == "init" then + q.gameflow = "running" + q.currentLevel = q.levelIntro + q.lvlState = q.lvlSetup + q.iCoins, q.iCredits, q.iScore = 0, 0, 0 + q.rndegg = random.new(math.floor(os.clock() * 100000)) + q.bShowCredits = true + q.bShowScore, q.bShowLives, q.bShowAction = false, false, false + return + end + local level = q.currentLevel + + if level == q.levelIntro then + T.doIntro(q) + elseif level == q.levelNormal then + T.doLevel(q) + elseif level == q.levelChoose then + q.chooseLevel() + elseif level == q.levelContinue then + T.doContinue(q) + elseif level == q.levelGameOver then + T.doGameOver(q) + elseif level == q.levelHighScore then + T.doHighScore(q) + elseif level == q.levelService then + -- The service menu is not played: back to the attract loop. + q.lvlState = q.lvlSetup + q.currentLevel = q.levelIntro + end + AUTHOR_VARS.score = q.iScore + AUTHOR_VARS.lives = q.iLives + AUTHOR_VARS.credits = q.iCredits + AUTHOR_VARS.level = q.iStageIndex + if q.bShowAction and q.move[q.currentMove] then + AUTHOR_VARS.prompt = q.move[q.currentMove][q.correctMove] + else + AUTHOR_VARS.prompt = "" + end + end, + on = function(instance, name, event) + local q = instance.q + local switch = event.switch or -1 + local flag = qteFlagOf(switch) + + if name == "pressed" then + if q.bPause or (flag == nil) then + return + end + if (flag == "p1BUTTON1") or (flag == "p1SERVICE") or (flag == "p1START1") or (flag == "p1COIN1") or (flag == "p1COIN2") or (flag == "p1UP") or (flag == "p1DOWN") or (flag == "p1LEFT") or (flag == "p1RIGHT") then + q[flag] = true + end + elseif name == "released" then + if switch == SWITCH_PAUSE then + q.bPause = not q.bPause + return + end + if (q.gameflow ~= "running") or q.bPause or (flag == nil) then + return + end + if ((flag == "p1COIN1") or (flag == "p1COIN2")) and (q.currentLevel ~= q.levelService) then + q.p1COIN1, q.p1COIN2 = false, false + if (q.dip_CoinsPerCredit ~= q.DOPT_FREEPLAY) and (q.iCredits < 9) then + q.iCoins = q.iCoins + 1 + q.resetChannels() + if q.iCoins >= q.dip_CoinsPerCredit then + q.iCoins = q.iCoins - q.dip_CoinsPerCredit + q.iCredits = q.iCredits + 1 + Q.sound(q, q.sndcredit) + else + Q.sound(q, q.sndsel) + end + if q.currentLevel == q.levelContinue then + q.bResetContinue = true + end + end + elseif flag ~= "p1SERVICE" then + q[flag] = false + end + end + end +} + + +-- ===== The Super Don Quixote game 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 +-- 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 +-- (after a beaten level, after an unfinished one) the converter reads out as data. + +local S = {} + + +function S.scanInput(q) + if q.p1UP then return q.UP + elseif q.p1DOWN then return q.DOWN + elseif q.p1LEFT then return q.LEFT + elseif q.p1RIGHT then return q.RIGHT + elseif q.p1BUTTON1 then return q.BUTTON1 + elseif q.p1BUTTON2 then return q.BUTTON2 + elseif q.p1BUTTON3 then return q.BUTTON3 end + + return q.NOMOVE +end + + +function S.addPoints(q, thisMuch) + q.iScore = q.iScore + thisMuch + q.iExtraLife = q.iExtraLife + thisMuch + if (q.EXTRALIFE > 0) and (q.iExtraLife >= q.EXTRALIFE) then + q.iExtraLife = 0 + if q.iLives < q.dip_LivesPerCredit then + Q.sound(q, q.sndvictory) + q.iLives = q.iLives + 1 + end + end + if q.iScore > q.iTop then + q.iTop = q.iScore + end + if q.iScore > 99999999 then + q.iScore = 99999999 + end +end + + +function S.checkHold(q, playerMove, curMove) + local z = S.scanInput(q) + local m = q.move[q.currentMove] + + if (q.currentFrame == m[q.inputFrmStart]) and (z == playerMove) then + z = q.MOVEFAIL + elseif z == playerMove then + if q.iLenHold >= q.lenCounter then + z = curMove + q.iLenHold = 0 + else + z = q.MOVEPENDING + if q.bTestHold and (q.currentFrame == q.lastHold) then + q.lastHold = q.currentFrame + elseif q.bTestHold and (q.currentFrame == q.lastHold + 1) then + q.iLenHold = q.iLenHold + 1 + q.lastHold = q.currentFrame + else + q.lastHold = q.currentFrame + end + end + elseif z ~= q.NOMOVE then + q.iLenHold = 0 + z = q.MOVEFAIL + else + if q.iLenHold > 0 then + q.iLenHold = q.iLenHold - 1 + end + z = q.MOVEPENDING + end + + return z +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 + + if playerMove == q.BUTTON1 then + q.bTestMash = false + if q.iMash >= 2 then + z = curMove + end + elseif playerMove ~= q.NOMOVE then + q.iMash = 0 + z = q.MOVEFAIL + end + + return z +end + + +function S.checkSkip(q, playerMove, curMove) + if (playerMove >= q.UP) and (playerMove <= q.BUTTON3) then + Q.clearInput(q) + discSkipToFrame(q.move[q.currentMove][q.inputFrmEnd]) + end + + return curMove +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) + if q.dip_Hints then + Q.sound(q, q.sndwrong) + discSkipToFrame(q.frameHints) + Q.timerON(q, 2) + discPause() + q.lvlState = q.branch03 + else + q.iWrongMv = q.iWrongMv + 1 + if q.dip_GameType == 4 then + q.bTestMash, q.bTestHold, q.iMash, q.iLenHold, q.bCalc = false, false, 0, 0, true + Q.sound(q, q.sndwrong) + q.lvlState = q.lvlPlayRest + else + q.setupDeathClip(q.thisMove) + end + end +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) + q.iPath = target + if q.iPath > 1000 then + q.move[q.currentMove][q.moveDeath] = q.iPath - 1000 + q.bShowAction = false + q.iPath = 0 + q.iWrongMv = q.iWrongMv + 1 + q.setupDeathClip(q.thisMove) + else + q.iPathAend, q.iPathAjmp = aEnd, aJump + q.bShowAction = false + q.lvlState = q.branch11 + end +end + + +-- A death clip has ended: a life was already taken by setupDeathClip; on by the rewind dip. +local function sdqAfterDeath(q, thisLevel) + q.stage[thisLevel][q.DEATHCOUNT] = q.stage[thisLevel][q.DEATHCOUNT] + 1 + if q.iLives <= 0 then + q.lvlState = q.lvlEnd + return + end + local after = q.move[q.currentMove + 1] + local last = (q.currentMove == q.totalMoves) or (after and (q.currentMove + 1 == q.totalMoves) and ((after[q.correctMove] == q.CHOOSE) or (after[q.correctMove] == q.LETGO) or (after[q.correctMove] == q.PATH) or (after[q.correctMove] == q.YESNO))) + + if q.dip_Rewind == 0 then + q.bRes, q.curPath, q.bPath = true, 0, true + q.lvlState = q.lvlEnd + elseif q.dip_Rewind == 2 then + q.bRes, q.iSegPointer, q.curPath, q.bPath = true, 0, 0, true + q.lvlState = q.lvlSetup + elseif (q.dip_Rewind == 3) and last then + q.scene[q.iCurPos][q.iSegPointer][q.SCENECOMPLETE] = true + q.lvlState = q.lvlEnd + elseif q.ShowResurrect then + Q.setupClip(q, q.offsetGetReady, q.offsetGetReady) + q.lvlState = q.branch09 + else + discSkipToFrame(q.iPauseFrame) + if (q.dip_Rewind == 3) and after and (after[q.correctMove] == q.LETGO) then + q.currentMove = q.currentMove + 2 + else + q.currentMove = q.currentMove + 1 + end + q.lvlState = q.lvlRunning + end +end + + +-- The scene begins: its intro clip, or the disc put at its start. +local function sdqStartScene(q, thisLevel) + if not q.stage[thisLevel][q.LEVELSTARTED] then + q.stage[thisLevel][q.LEVELSTARTED] = true + if q.bSave and (q.currentMove ~= 1) then + q.currentFrame = q.move[q.currentMove - 1][q.inputFrmEnd] + 1 + discSkipToFrame(q.currentFrame) + q.bSave = false + q.lvlState = q.lvlRunning + elseif (not q.bSkipIntroClip) and ((q.dip_StartLevel ~= thisLevel) or (q.dip_StartScene == 1)) then + q.bShowSkip = true + q.getIntroClip(thisLevel) + q.lvlState = q.branch01 + else + discSkipToFrame(q.sceneStart) + q.lvlState = q.lvlRunning + end + else + if q.currentFrame + 1 ~= q.sceneStart then + discSkipToFrame(q.sceneStart) + end + q.lvlState = q.lvlRunning + end +end + + +function S.doLevel(q) + local thisLevel = q.iCurPos + + if q.lvlState == q.lvlSetup then + q.bShuffleOrder, q.bPlayPrompt, q.bPlayRight = true, true, true + q.bShowLvl, q.bShowAction, q.bAct, q.bTestMash, q.bTestHold = false, false, false, false, false + q.iMash, q.iLenHold, q.bPath, q.bCalc = 0, 0, true, true + q.bShowScore, q.bShowLives = (q.dip_GameType ~= 4), (q.dip_GameType ~= 4) + if not q.bSave then + q.currentMove = 1 + end + q.setupLevel(thisLevel) + if q.ShowResurrect and q.bRes then + Q.setupClip(q, q.offsetGetReady, q.offsetGetReadyEnd) + q.bShowGet = true + q.lvlState = q.branch08 + q.bRes = false + elseif not q.stage[thisLevel][q.LEVELSTARTED] then + q.stage[thisLevel][q.LEVELSTARTED] = true + if q.bSave and (q.currentMove ~= 1) then + q.currentFrame = q.move[q.currentMove - 1][q.inputFrmEnd] + 1 + discSkipToFrame(q.currentFrame) + q.bSave = false + q.lvlState = q.lvlRunning + elseif (not q.bSkipIntroClip) and ((q.dip_StartLevel ~= thisLevel) or (q.dip_StartScene == 1)) then + q.bShowSkip = true + q.getIntroClip(thisLevel) + q.lvlState = q.branch01 + else + discSkipToFrame(q.sceneStart) + q.lvlState = q.lvlRunning + end + else + if not ((q.currentFrame == q.sceneStart) or (q.currentFrame + 1 == q.sceneStart) or (q.currentFrame - 1 == q.sceneStart)) then + discSkipToFrame(q.sceneStart) + end + q.lvlState = q.lvlRunning + end + elseif q.lvlState == q.branch01 then + if (q.currentFrame == q.iFrameEnd) or q.p1BUTTON1 then + q.p1BUTTON1 = false + q.bShowLvl, q.bShowSkip = false, false + if q.currentFrame ~= q.iFrameEnd then + discSkipToFrame(q.sceneStart) + end + q.lvlState = q.lvlRunning + end + elseif q.lvlState == q.branch02 then + sdqWrong(q) + elseif q.lvlState == q.branch03 then + if Q.timerDue(q) then + q.setupDeathClip(q.thisMove) + end + elseif q.lvlState == q.branch04 then + if q.currentFrame >= q.sceneEnd then + q.lvlState = q.lvlEnd + end + elseif q.lvlState == q.branch05 then + if Q.timerDue(q) then + q.bGOAlt = true + local fresh = (thisLevel ~= q.levelExt) and q.AllowSecret + + if fresh and (q.BeatGameWithOneLife() or q.BeatGameWithOneCredit()) then + Q.sound(q, q.sndvictory) + discSkipToFrame(q.frameSecret) + discPause() + Q.timerON(q, 4) + q.lvlState = q.branch06 + elseif q.newScore(q.iScore) then + q.lvlState, q.currentLevel = q.lvlSetup, q.levelHighScore + else + q.lvlState, q.currentLevel = q.lvlSetup, q.levelGameOver + end + end + elseif q.lvlState == q.branch06 then + if Q.timerDue(q) then + q.bExtendedPlay = true + q.iSegPointer = 0 + S.startGame(q) + end + elseif q.lvlState == q.branch07 then + -- The extra death clip has ended. + if q.currentFrame == q.iFrameEnd then + sdqAfterDeath(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) + end + elseif q.lvlState == q.branch09 then + -- The "get ready" clip after a death has ended: back to the move after it. + if q.currentFrame == q.iFrameEnd then + local after = q.move[q.currentMove + 1] + + q.lvlState = q.lvlRunning + discSkipToFrame(q.iPauseFrame) + if (q.dip_Rewind == 3) and after and (after[q.correctMove] == q.LETGO) then + q.currentMove = q.currentMove + 2 + else + q.currentMove = q.currentMove + 1 + end + end + elseif q.lvlState == q.branch10 then + S.doChoose(q) + elseif q.lvlState == q.branch11 then + Q.clearInput(q) + q.lvlState = q.lvlPlayRest + if q.bPlayRight then + Q.sound(q, q.sndright) + q.bPlayRight = false + end + local worth = q.SCOREMOVE + q.dip_Difficulty * q.BUFFMOVE + + S.addPoints(q, worth) + q.iScoreTemp = q.iScoreTemp + worth + q.iRightMv = q.iRightMv + 1 + elseif q.lvlState == q.lvlPlayRest then + q.bPlayRight = true + if q.currentMove < q.totalMoves then + q.bCheckMove = true + end + q.thisMove = S.scanInput(q) + if q.currentFrame >= q.move[q.currentMove][q.moveFrmEnd] then + q.bCheckMove = false + if (q.iPath ~= 0) and (q.currentMove < q.totalMoves) then + q.currentMove = q.iPath + q.iPath, q.bPath = 0, true + elseif (q.iPathAend ~= 0) and (q.currentMove == q.iPathAend) then + q.currentMove = q.iPathAjmp + q.iPathAend, q.iPathAjmp = 0, 0 + else + q.currentMove = q.currentMove + 1 + end + if q.currentMove <= q.totalMoves then + if q.currentFrame + 1 ~= q.move[q.currentMove][q.moveFrmStart] then + discSkipToFrame(q.move[q.currentMove][q.moveFrmStart]) + end + q.bPlayPrompt, q.bAct = true, false + if q.move[q.currentMove][q.correctMove] == q.CHOOSE then + q.altState = q.lvlSetup + q.lvlState = q.branch10 + else + q.lvlState = q.lvlRunning + end + else + local bonus = q.SCORESCENE - q.stage[thisLevel][q.DEATHCOUNT] * q.DEATHPENALTY + + q.scene[thisLevel][q.iSegPointer][q.SCENECOMPLETE] = true + if ((thisLevel == q.finalstage) and q.BeatLevel(q.finalstage)) or ((thisLevel == q.levelExt) and q.BeatLevel(q.levelExt)) then + if thisLevel == q.finalstage then + q.stage[thisLevel][q.BEATSTATUS] = true + end + if q.dip_GameType ~= 4 then + q.doTrophy() + end + if q.BeatGame() then + if bonus > 0 then + S.addPoints(q, bonus) + end + S.addPoints(q, q.SCOREGAME) + if q.BeatGameWithOneLife() then + S.addPoints(q, q.SCORESECRET) + end + else + if bonus > 0 then + S.addPoints(q, bonus) + end + S.addPoints(q, q.SCORELEVEL) + end + if thisLevel == q.levelExt then + discPause() + Q.timerON(q, 0.1) + else + Q.sound(q, q.sndvictory) + discSkipToFrame(q.frameVictory) + discPause() + Q.timerON(q, 3) + end + q.lvlState = q.branch05 + else + if bonus > 0 then + S.addPoints(q, bonus) + q.iBonus = q.iBonus + bonus + end + q.lvlState = q.branch04 + end + end + end + elseif q.lvlState == q.lvlRunning then + local m = q.move[q.currentMove] + local kind = m[q.correctMove] + + if (q.currentFrame >= m[q.inputFrmStart]) and (q.currentFrame <= m[q.inputFrmEnd]) then + q.bShowAction, q.bShowLvl = true, false + if q.bPlayPrompt and (q.dip_ShowAction == 1) and (kind <= q.HOLDRIGHT) then + Q.sound(q, q.sndcoin) + q.bPlayPrompt = false + end + local function judged(thisMove) + if thisMove == kind then + return true + elseif thisMove ~= q.MOVEPENDING then + q.iPauseFrame = m[q.inputFrmEnd] + q.bShowAction = false + q.lvlState = q.branch02 + end + return false + end + + if (kind >= q.HOLDUP) and (kind <= q.HOLDBUT) then + q.bTestHold = true + if q.bCalc then + q.bCalc = false + q.lenCounter = (m[q.inputFrmEnd] - m[q.inputFrmStart]) - (13 - q.dip_Difficulty) + end + q.thisMove = S.checkHold(q, kind - 20, kind) + if judged(q.thisMove) then + q.thisMove = S.scanInput(q) + q.bTestHold, q.iLenHold, q.lastHold, q.bCalc = false, 0, 0, true + q.lvlState = q.branch11 + end + elseif kind == q.LETGO then + q.thisMove = S.checkLet(q, S.scanInput(q), kind) + if judged(q.thisMove) then + q.lvlState = q.branch11 + end + elseif (kind >= q.MASH) and (kind <= q.MASHMAX) then + q.thisMove = S.scanInput(q) + q.bTestMash = true + if q.bCalc then + q.bCalc = false + 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) + if judged(q.thisMove) then + q.bTestMash, q.iMash, q.bCalc = false, 0, true + q.lvlState = q.branch11 + end + elseif kind == q.DOUBLE then + q.thisMove = S.scanInput(q) + q.bTestMash = true + q.thisMove = S.checkDouble(q, q.thisMove, kind) + if judged(q.thisMove) then + q.bTestMash, q.iMash = false, 0 + q.lvlState = q.branch11 + end + elseif kind <= q.BUTTON4 then + q.thisMove = S.scanInput(q) + if (q.thisMove ~= q.NOMOVE) and judged(q.thisMove) then + q.lvlState = q.branch11 + end + elseif (kind >= q.UPLEFT) and (kind <= q.ACTRIGHT) then + local pair = pairFor(q, kind) + local other = false + + Q.timerON(q, (m[q.inputFrmEnd] - q.currentFrame) / q.MovieFPS) + for _, name in ipairs({ "p1UP", "p1DOWN", "p1LEFT", "p1RIGHT", "p1BUTTON1", "p1BUTTON2", "p1BUTTON3" }) do + if q[name] and (name ~= pair[1]) and (name ~= pair[2]) then + other = true + end + end + if Q.timerDue(q) or other then + q.iPauseFrame = m[q.inputFrmEnd] + q.bShowAction = false + q.lvlState = q.branch02 + elseif q[pair[1]] and q[pair[2]] then + q.bAct = false + q.lvlState = q.branch11 + end + elseif kind == q.PATH then + q.thisMove = S.scanInput(q) + if q.bPath then + q.curPath = q.curPath + 1 + q.bPath = false + end + if q.thisMove ~= q.NOMOVE then + local p = q.path[q.curPath] + + if q.thisMove == p[1] then + sdqPath(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) + else + sdqPath(q, p[5], p[6] - 1, p[7]) + end + elseif q.thisMove == p[3] then + sdqPath(q, p[6], 0, 0) + else + q.setupDeathClip(q.thisMove) + end + end + elseif kind == q.YESNO then + q.thisMove = S.scanInput(q) + if q.bPath then + q.curPath = q.curPath + 1 + q.bPath = false + end + local p = q.path[q.curPath] + + if q.thisMove ~= q.NOMOVE then + if q.thisMove == q.BUTTON1 then + sdqPath(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) + end + elseif kind == q.SKIP then + q.thisMove = S.checkSkip(q, S.scanInput(q), kind) + end + elseif (q.currentFrame > m[q.inputFrmEnd]) and (kind ~= q.SKIP) then + q.iPauseFrame = m[q.inputFrmEnd] + q.bShowAction = false + q.lvlState = q.branch02 + elseif q.currentFrame > m[q.inputFrmEnd] then + q.bShowAction = false + q.lvlState = q.lvlPlayRest + else + S.scanInput(q) + end + elseif q.lvlState == q.lvlPlayDeath then + if q.currentFrame == q.iFrameEnd then + if q.ShowSupDeath then + Q.setupClip(q, q.offsetSupDeath, q.offsetSupDeathEnd) + q.lvlState = q.branch07 + else + sdqAfterDeath(q, thisLevel) + end + end + elseif q.lvlState == q.lvlEnd then + q.lvlState = q.lvlSetup + if q.iLives == 0 then + if (q.dip_LimitContinue > 0) and ((q.iContinues < q.dip_LimitContinue) or (q.dip_LimitContinue == q.DOPT_INFINITE_CONTINUES)) then + q.iTempLevel = q.currentLevel + q.currentLevel = q.levelContinue + q.iContinues = q.iContinues + 1 + else + q.currentLevel = q.newScore(q.iScore) and q.levelHighScore or q.levelGameOver + end + else + local order = q.snapshot.sdq or {} + + if q.BeatLevel(thisLevel) then + if q.dip_GameType ~= 4 then + q.doTrophy() + end + q.stage[thisLevel][q.BEATSTATUS] = true + q.levelMap[thisLevel] = true + if q.dip_GameType == 3 then + -- The ancestor autosaves here; the port keeps no saves. + q.bShowDiskA = true + q.altState = q.branch01 + end + q.iSegPointer = 0 + S.addPoints(q, q.SCORELEVEL) + q.iBonus = q.iBonus + q.SCORELEVEL + if q.stage[thisLevel][q.DEATHCOUNT] == 0 then + S.addPoints(q, q.PERFECTBONUS) + q.iBonus = q.iBonus + q.PERFECTBONUS + end + q.bSkipIntroClip = false + q.iLiveSave, q.iScoreSave, q.bAllowSave, q.bRes = q.iLives, q.iScore, true, true + if q.ShowLvlClear then + Q.sound(q, q.sndclear) + q.lvlState, q.currentLevel = q.lvlSetup, q.levelFinish + else + q.iScoreTemp, q.iBonus = 0, 0 + if q.dip_GameType == 3 then + q.lvlState, q.currentLevel = q.lvlSetup, q.levelMenuScreen + elseif (order.beaten or {})[thisLevel] then + q.iCurPos = order.beaten[thisLevel] + end + end + elseif q.CompleteLevel then + if q.iPath ~= 0 then + q.iSegPointer, q.iPath = q.iPath, 0 + end + if not q.scene[thisLevel][q.iSegPointer][q.SCENECOMPLETE] then + if q.iSegPointer > 0 then + q.iSegPointer = q.iSegPointer - 1 + end + else + q.iLiveSave, q.iScoreSave, q.bAllowSave = q.iLives, q.iScore, true + end + elseif (order.unfinished or {})[thisLevel] then + q.iCurPos = order.unfinished[thisLevel] + end + q.bTestMash, q.bTestHold, q.iMash, q.iLenHold = false, false, 0, 0 + end + end +end + + +-- A choice (none of the game's rows make one): the Karis shape, a wrong option a death. +function S.doChoose(q) + local m = q.move[q.currentMove] + + if q.altState == q.lvlSetup then + q.altState, q.iChoice, q.bIgnoreJoy, q.bShowChoices = q.lvlRunning, 1, false, true + elseif q.altState == q.lvlRunning then + local worth = q.SCOREMOVE + q.dip_Difficulty * q.BUFFMOVE + local function take(skip) + q.bShowChoices = false + if q.choice[q.optorder[q.iChoice]][2] == true then + Q.sound(q, q.sndright) + S.addPoints(q, worth) + q.iScoreTemp = q.iScoreTemp + worth + q.iRightMv = q.iRightMv + 1 + if skip then + discSkipToFrame(m[q.inputFrmEnd]) + end + q.lvlState = q.lvlPlayRest + else + m[q.moveDeath] = q.choice[q.optorder[q.iChoice]][3] + q.iWrongMv = q.iWrongMv + 1 + q.setupDeathClip(q.thisMove) + q.lvlState = q.lvlPlayDeath + end + end + + if q.currentFrame > m[q.inputFrmEnd] then + take(false) + elseif (q.currentFrame >= m[q.inputFrmStart]) and (q.currentFrame <= m[q.inputFrmEnd]) then + local thisMove = q.NOMOVE + + if q.bIgnoreJoy then + if Q.timerDue(q) then + q.bIgnoreJoy = false + end + else + thisMove = S.scanInput(q) + end + if thisMove == q.UP then + q.p1UP = false + if q.iChoice > 1 then + q.iChoice = q.iChoice - 1 + Q.sound(q, q.sndcoin) + end + elseif thisMove == q.DOWN then + q.p1DOWN = false + if q.iChoice < m[q.moveDeath] then + q.iChoice = q.iChoice + 1 + Q.sound(q, q.sndcoin) + end + elseif thisMove == q.BUTTON1 then + q.p1BUTTON1 = false + take(true) + end + end + end +end + + +function S.startGame(q) + q.initStages() + q.curPath = 0 + if q.bExtendedPlay then + q.currentLevel, q.iCurPos = q.levelNormal, q.levelExt + else + if q.iCredits > 0 then + q.iCredits = q.iCredits - 1 + end + q.iScore, q.iScoreTemp, q.iBonus = 0, 0, 0 + if q.currentLevel == q.levelContinue then + q.currentLevel = q.iTempLevel + if q.dip_Rewind == 1 then + q.currentMove, q.bSave = q.currentMove + 1, true + elseif q.dip_Rewind == 3 then + q.currentMove = (q.currentMove == q.totalMoves) and (q.currentMove - 1) or (q.currentMove + 1) + q.bSave = true + elseif q.dip_Rewind == 2 then + q.iSegPointer = 0 + end + else + q.iContinues, q.iSegPointer = 0, 0 + if q.dip_GameType == 0 then + q.doMixSEQ() + q.iCurPos, q.iSegPointer, q.currentLevel = q.dip_StartLevel, q.dip_StartScene - 1, q.levelNormal + elseif q.dip_GameType == 1 then + q.doMixRND() + q.iCurPos, q.currentLevel = q.LvlOrder[1], q.levelNormal + elseif q.dip_GameType == 2 then + q.doMixTIE() + q.iCurPos, q.currentLevel = q.LvlOrder[1], q.levelNormal + elseif q.dip_GameType == 3 then + q.iCurPos, q.lvlState = q.PlayOrder[1], q.lvlSetup + q.currentLevel = (q.MapStart == 0) and q.levelMenuScreen or q.levelNormal + elseif q.dip_GameType == 4 then + q.iRightMv, q.iWrongMv = 0, 0 + q.iCurPos, q.iSegPointer, q.currentLevel = q.dip_StartLevel, q.dip_StartScene - 1, q.levelNormal + end + end + end + if q.IngameDiffchoice and (q.dip_Diffshow == 4) and q.bOneDiff then + q.altState = q.lvlSetup + q.currentLevel = q.levelDiffScreen + end + q.lvlState = q.lvlSetup + q.bRes = true + q.iLives = q.dip_LivesPerCredit + q.bResetContinue, q.bExtendedPlay = false, false +end + + +-- The attract loop: the intro clip, the controls and the specials stills, a filler, the +-- rankings, the percents, the trophies, another filler, and round; the secret's combination +-- at three of its stops. +function S.doIntro(q) + local function secret() + if q.p1BUTTON2 and q.p1BUTTON3 and q.p1UP and q.p1RIGHT and q.AllowSecret then + q.p1BUTTON2, q.p1BUTTON3, q.p1UP, q.p1RIGHT = false, false, false, false + q.bExtendedPlay = true + S.startGame(q) + return true + end + return false + end + local function still(frame, hold, next) + q.p1BUTTON1 = false + discSkipToFrame(frame) + discPause() + Q.timerON(q, hold) + q.lvlState = next + end + + if q.lvlState == q.lvlSetup then + Q.setupClip(q, q.offsetIntro01, q.offsetIntro01end) + q.initLCD() + q.lvlState = q.branch01 + q.bCheckForCredits = true + elseif q.lvlState == q.branch01 then + if (q.currentFrame == q.iFrameEnd) or q.p1BUTTON1 then + still(q.frameControls, 10, q.branch02) + else + secret() + end + elseif q.lvlState == q.branch02 then + if Q.timerDue(q) or q.p1BUTTON1 then + still(q.frameSpecial, 10, q.branch03) + end + elseif q.lvlState == q.branch03 then + if Q.timerDue(q) or q.p1BUTTON1 then + q.p1BUTTON1 = false + q.initLCD() + q.doFillerFrame() + q.lvlState = q.branch04 + end + elseif q.lvlState == q.branch04 then + if (q.currentFrame == q.iFrameEnd) or q.p1BUTTON1 then + still(q.frameRankings, 6, q.branch05) + else + secret() + end + elseif q.lvlState == q.branch05 then + if Q.timerDue(q) or q.p1BUTTON1 then + still(q.frameRankings, 6, q.branch06) + end + elseif q.lvlState == q.branch06 then + if Q.timerDue(q) or q.p1BUTTON1 then + still(q.frameTrophy, 6, q.branch07) + end + elseif q.lvlState == q.branch07 then + if Q.timerDue(q) or q.p1BUTTON1 then + q.p1BUTTON1 = false + q.initLCD() + q.doFillerFrame() + q.lvlState = q.branch08 + else + secret() + end + elseif q.lvlState == q.branch08 then + if (q.currentFrame == q.iFrameEnd) or q.p1BUTTON1 then + q.p1BUTTON1 = false + q.lvlState = q.lvlSetup + end + end + if q.dip_CoinsPerCredit == q.DOPT_FREEPLAY then + -- The ancestor compares its movie dip, a number, with booleans: neither ever holds. + if q.p1START1 and (q.dip_Movie == false) then + q.p1START1 = false + S.startGame(q) + elseif q.p1START1 and (q.dip_Movie == true) then + q.p1START1 = false + q.lvlState = q.lvlSetup + q.currentLevel = q.levelMovie + end + elseif q.bShowCredits and ((q.iCoins > 0) or (q.iCredits > 0)) then + discSkipToFrame(1500) + discPause() + if q.p1START1 and (q.iCredits > 0) then + q.p1START1 = false + S.startGame(q) + end + end +end + + +-- The difficulty select: a still per difficulty, the stick moving between them. +function S.moveFrameDiff(q) + local function step(frame, level) + Q.sound(q, q.sndcoin) + discSkipToFrame(frame) + discPause() + q.p1LEFT, q.p1RIGHT = false, false + q.dip_Difficulty = level + end + + if q.currentFrame == q.frameEasy then + if q.p1RIGHT then step(q.frameNormal, 1) end + elseif q.currentFrame == q.frameNormal then + if q.p1LEFT then step(q.frameEasy, 0) elseif q.p1RIGHT then step(q.frameHard, 2) end + elseif q.currentFrame == q.frameHard then + if q.p1LEFT then step(q.frameNormal, 1) elseif q.p1RIGHT then step(q.frameExtreme, 3) end + elseif q.currentFrame == q.frameExtreme then + if q.p1LEFT then step(q.frameHard, 2) end + end +end + + +function S.doDiffSelect(q) + if q.altState == q.lvlSetup then + q.bIgnoreJoy = false + discSkipToFrame(q.frameEasy) + discPause() + Q.timerON(q, 30) + q.altState = q.lvlRunning + elseif q.altState == q.lvlRunning then + if Q.timerDue(q) then + q.altState = q.lvlEnd + elseif q.p1BUTTON1 then + Q.sound(q, q.sndcredit) + q.p1BUTTON1 = false + q.altState = q.lvlEnd + else + S.moveFrameDiff(q) + end + elseif q.altState == q.lvlEnd then + q.lvlState, q.currentLevel = q.lvlSetup, q.levelNormal + end +end + + +-- The level's end: the ancestor runs doFinish and then doClear every frame of it, over one +-- state; so does this, in that order. +function S.doFinish(q) + local iTemp = math.floor(100 * (q.iRightMv / (q.iRightMv + q.iWrongMv))) + + q.bShowScore, q.bRes = false, true + if q.lvlState == q.lvlSetup then + Q.setupClip(q, q.offsetClear, q.offsetClearend) + q.lvlState = q.branch01 + elseif q.lvlState == q.branch01 then + if q.currentFrame == q.iFrameEnd then + discPause() + Q.timerON(q, 0.1) + q.lvlState = q.branch02 + end + elseif q.lvlState == q.branch02 then + if Q.timerDue(q) then + if q.numTrophy < iTemp then + if Q.timerDue(q) then + q.numTrophy = q.numTrophy + 1 + Q.timerON(q, 0.01) + Q.sound(q, q.sndroll) + end + else + Q.sound(q, q.sndvictory) + Q.timerON(q, 5) + q.lvlState = q.branch03 + end + end + elseif q.lvlState == q.branch03 then + if Q.timerDue(q) then + q.iScoreTemp, q.iBonus, q.numTrophy = 0, 0, 0 + if q.newPercent(iTemp) and (q.dip_Difficulty > 0) then + q.lvlState, q.currentLevel = q.lvlSetup, q.levelHighScore + else + q.lvlState, q.currentLevel = q.lvlSetup, q.levelIntro + end + end + end +end + + +function S.doClear(q) + q.bRes = true + if q.lvlState == q.lvlSetup then + Q.setupClip(q, q.offsetClear, q.offsetClearend) + q.lvlState = q.branch01 + elseif q.lvlState == q.branch01 then + if q.currentFrame == q.iFrameEnd then + discPause() + Q.timerON(q, 2) + q.lvlState = q.branch02 + end + elseif q.lvlState == q.branch02 then + if Q.timerDue(q) then + if q.iBonus > 0 then + if Q.timerDue(q) then + q.iBonus = q.iBonus - 1000 + q.iScoreTemp = q.iScoreTemp + 1000 + Q.timerON(q, 0.01) + Q.sound(q, q.sndroll) + end + else + Q.sound(q, q.sndvictory) + Q.timerON(q, 3) + q.lvlState = q.branch03 + end + end + elseif q.lvlState == q.branch03 then + if Q.timerDue(q) then + q.iScoreTemp, q.iBonus, q.numTrophy = 0, 0, 0 + if q.dip_GameType == 3 then + q.lvlState, q.currentLevel = q.lvlSetup, q.levelMenuScreen + else + q.NextLevel(q.iCurPos) + q.lvlState, q.currentLevel = q.lvlSetup, q.levelNormal + end + end + end +end + + +function S.doContinue(q) + if q.lvlState == q.lvlSetup then + Q.setupClip(q, q.offsetContinue, q.offsetContinueend) + q.lvlState = q.lvlRunning + elseif q.lvlState == q.lvlRunning then + if q.currentFrame == q.iFrameEnd then + q.lvlState = q.lvlEnd + elseif q.p1START1 then + q.p1START1 = false + if (q.iCredits > 0) or (q.dip_CoinsPerCredit == q.DOPT_FREEPLAY) then + q.bOneDiff, q.bSkipIntroClip = false, true + if q.iSegPointer > 0 then + q.iSegPointer = q.iSegPointer - 1 + end + S.startGame(q) + end + elseif q.p1BUTTON2 then + q.p1BUTTON2 = false + q.lvlState = q.lvlEnd + end + elseif q.lvlState == q.lvlEnd then + q.lvlState, q.bSkipIntroClip = q.lvlSetup, false + if q.newScore(q.iScore) then + q.currentLevel, q.bGOAlt = q.levelHighScore, true + else + q.currentLevel = q.levelGameOver + end + end +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.", + params = {}, + attach = function(instance, params) + -- The state the rdg loop keeps, and then this game's own. + AUTHOR.behaviours.rdg.attach(instance, params) + local q = instance.q + + q.sdq, q.later = true, false + q.percent = {} + for k, entry in ipairs(q.snapshot.percents or {}) do + q.percent[k] = { entry[1], entry[2] } + end + -- The dips as the game's readConfig leaves them: the extra clips by a table of + -- eight, hints a boolean, the action prompt and the movie flag numbers. + local extra = ({ [0] = {}, [1] = { r = true }, [2] = { s = true }, [3] = { c = true }, [4] = { r = true, s = true }, [5] = { r = true, c = true }, [6] = { s = true, c = true }, [7] = { r = true, s = true, c = true } })[q.dip_Extravid or 0] or {} + + q.ShowResurrect, q.ShowSupDeath, q.ShowLvlClear = extra.r or false, extra.s or false, extra.c or false + q.dip_Hints = ((q.snapshot.dips or {}).dip_Hints == 1) + q.dip_ShowAction = (q.snapshot.dips or {}).dip_ShowAction or 0 + q.dip_Movie = (q.snapshot.dips or {}).dip_Movie or 0 + q.iPenal = ({ [0] = 0, [1] = q.PenalNormal, [2] = q.PenalHard, [3] = q.PenalExtreme })[q.dip_Difficulty] or 0 + q.iRightMv, q.iWrongMv, q.iExtraLife, q.iTop, q.numTrophy = 0, 0, 0, 0, 0 + q.iPath, q.iPathAend, q.iPathAjmp, q.curPath, q.bPath, q.bCalc = 0, 0, 0, 0, false, true + q.iLenHold, q.lastHold, q.lenCounter, q.unMash, q.mashCounter = 0, 0, q.lenCounter or 8, q.unMash or 0.14, q.mashCounter or 5 + q.bOneDiff, q.bGOAlt, q.bPlayRight, q.bTestHold, q.bShowGet = true, false, true, false, false + q.thisMove = q.NOMOVE + end, + step = function(instance) + local q = instance.q + + q.currentFrame = discGetFrame() + if q.gameflow == "vldp" then + if q.lvlState == q.lvlSetup then + Q.setupClip(q, q.offsetTitle, q.offsetTitleend) + q.bPause = true + q.lvlState = q.lvlRunning + elseif q.lvlState == q.lvlRunning then + if q.currentFrame == q.iFrameEnd then + discPause() + q.lvlState = q.lvlEnd + end + elseif q.lvlState == q.lvlEnd then + q.bPause = false + q.gameflow = "init" + q.lvlState = q.lvlSetup + end + return + end + if q.gameflow == "init" then + q.gameflow = "running" + q.currentLevel = q.levelIntro + q.lvlState = q.lvlSetup + q.iCoins, q.iScore, q.iScoreTemp, q.iBonus, q.iSegPointer = 0, 0, 0, 0, 0 + for k = 1, 16 do + q.levelMap[k] = false + end + q.bAct = false + q.initLCD() + return + end + local level = q.currentLevel + + if level == q.levelIntro then + S.doIntro(q) + elseif level == q.levelNormal then + S.doLevel(q) + elseif level == q.levelMenuScreen then + q.doLevelSelect() + elseif level == q.levelDiffScreen then + S.doDiffSelect(q) + elseif level == q.levelContinue then + S.doContinue(q) + elseif level == q.levelGameOver then + R.doGameOver(q) + elseif level == q.levelHighScore then + R.doHighScore(q) + elseif level == q.levelFinish then + S.doFinish(q) + S.doClear(q) + elseif (level == q.levelService) or (level == q.levelSave) or (level == q.levelMovie) then + -- The service and save menus and the movie player are not played. + q.lvlState, q.currentLevel = q.lvlSetup, q.levelIntro + end + AUTHOR_VARS.score = q.iScore + AUTHOR_VARS.lives = q.iLives + AUTHOR_VARS.credits = q.iCredits + AUTHOR_VARS.level = q.iCurPos + if q.bShowAction and q.move[q.currentMove] then + AUTHOR_VARS.prompt = q.move[q.currentMove][q.correctMove] + else + AUTHOR_VARS.prompt = "" + end + 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) + end +} diff --git a/assets/Forge/AuthorCompile.singe b/assets/Forge/AuthorCompile.singe index 4da9cea0e..3693d1d59 100644 --- a/assets/Forge/AuthorCompile.singe +++ b/assets/Forge/AuthorCompile.singe @@ -63,7 +63,9 @@ local FUNCTIONS = { max = "math.max", floor = "math.floor", time = "authorTime", - frame = "discGetFrame" + frame = "discGetFrame", + pointerX = "authorPointerX", + pointerY = "authorPointerY" } local NAMES = { @@ -781,6 +783,21 @@ function authorCompile(game) data[key] = value end end + -- A type's empty vars table says nothing the runtime does not assume; left out, so a + -- description that has one and one that has none compile the same. + if game.types then + data.types = {} + for name, entityType in pairs(game.types) do + local copy = {} + + for key, value in pairs(entityType) do + if not ((key == "vars") and (type(value) == "table") and (next(value) == nil)) then + copy[key] = value + end + end + data.types[name] = copy + end + end out[#out + 1] = "-- Generated by Forge/AuthorCompile.singe from " .. (game.source or "a game description") out[#out + 1] = "-- " .. (game.title or "Untitled") out[#out + 1] = "--" @@ -796,6 +813,17 @@ function authorCompile(game) out[#out + 1] = 'local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\\\])") or "")' out[#out + 1] = 'dofile(here .. "Author.singe")' out[#out + 1] = "AUTHOR_DIR = here -- Files the description names are looked for here first." + -- A description built where it lives -- a port beside its game's own files, a test -- names + -- files beside itself, which is not where the built script sits; they are looked for there + -- second. A released game has them beside the script, and a packed one inside the pack. + local sourceDir = game.source and string.match(game.source, "^(.*[/\\])") or "" + + if sourceDir ~= "" then + out[#out + 1] = "AUTHOR_SOURCE_DIR = " .. string.format("%q", sourceDir) .. " -- Then beside the description this was built from." + end + if game.vocabulary then + out[#out + 1] = "authorVocabulary(" .. string.format("%q", game.vocabulary) .. ") -- The game's own looks, behaviours, conditions, and actions." + end out[#out + 1] = "" out[#out + 1] = "-- The description, as the runtime wants it: everything but the rules, which follow as code." out[#out + 1] = "authorBegin(" .. valueSource(data, 0) .. ")" @@ -852,6 +880,10 @@ function authorBuild(descriptionFile, outputFile) local file game.source = descriptionFile + -- The game's own vocabulary, before the checker reads the manifest. + if game.vocabulary then + authorVocabulary(game.vocabulary, string.match(descriptionFile, "^(.*[/\\])") or "") + end for _, problem in ipairs(authorCheck(game)) do debugPrint("Author: " .. problem) end diff --git a/assets/Forge/Forge.singe b/assets/Forge/Forge.singe index b505f468f..009c32ee7 100644 --- a/assets/Forge/Forge.singe +++ b/assets/Forge/Forge.singe @@ -102,7 +102,7 @@ local NEW_H = 40 local STRIP_H = 34 -- The timeline along the bottom of the canvas. local STRIP_PAD = 8 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 } +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. local FRAME_STEP = 1 @@ -1024,6 +1024,44 @@ end -- The disc layer's file, if the description has one, loaded and paused so the timeline can show -- the frame under the cursor. +-- A frame file names several videos, each from a disc frame: the one with the longest run of +-- frames is the game's own, and is what the editor scrubs, its first frame remembered so the +-- cursor's disc frame lands on the right picture. Answers the video's path and its first frame. +local function frameFileVideo(path) + local folder = path:match("^(.*[/\\])") or "" + local entries = {} + local best = nil + + for line in io.lines(path) do + local start, name = line:match("^%s*(%d+)%s+(.-)%s*$") + + if start then + entries[#entries + 1] = { start = tonumber(start), name = name } + elseif (#entries == 0) and (line:match("%S")) and not line:match("^%s*%d") then + -- The first line is the directory the videos are in; its own is the usual. + local where = line:gsub("%s+$", ""):gsub("\\", "/") + + if (where ~= ".") and (where ~= "./") then + folder = folder .. where:gsub("/?$", "/") + end + end + end + for index, entry in ipairs(entries) do + local following = entries[index + 1] + + entry.span = following and (following.start - entry.start) or 0 + if (best == nil) or (entry.span > best.span) then + best = entry + end + end + if best == nil then + return nil, 0 + end + + return folder .. best.name, best.start +end + + local function loadVideo() local file @@ -1037,11 +1075,19 @@ local function loadVideo() videoUnload(FORGE.video) FORGE.video = nil end - FORGE.videoFile = file + FORGE.videoFile = file + FORGE.videoStart = 0 if file and (lfs.attributes(forgeFile(file), "mode") == "file") then - FORGE.video = videoLoad(forgeFile(file)) - videoPause(FORGE.video) - videoSeek(FORGE.video, FORGE.cursor) + local path = forgeFile(file) + + if path:lower():match("%.txt$") then + path, FORGE.videoStart = frameFileVideo(path) + end + if path and (lfs.attributes(path, "mode") == "file") then + FORGE.video = videoLoad(path) + videoPause(FORGE.video) + videoSeek(FORGE.video, math.max(0, FORGE.cursor - FORGE.videoStart)) + end end end end @@ -1053,6 +1099,11 @@ function forgeBegin(path) if FORGE.game == nil then return false end + -- The game's own vocabulary, in place of the last game's, before the panels read the manifest. + authorVocabularyForget() + if FORGE.game.vocabulary then + authorVocabulary(FORGE.game.vocabulary, string.match(path, "^(.*[/\\])") or "") + end FORGE.game.types = FORGE.game.types or {} FORGE.game.rules = FORGE.game.rules or {} FORGE.game.layers = FORGE.game.layers or {} @@ -1117,6 +1168,7 @@ function forgeClose() if FORGE.gui ~= nil then guiDelete(FORGE.gui) end + authorVocabularyForget() for _, cache in ipairs({ FORGE.sprites, FORGE.sheets }) do for _, sprite in pairs(cache) do if sprite then @@ -1239,7 +1291,7 @@ function forgeExport(folder, name) end -- The description travels with the game, so the release can be opened and edited again. - authorSave(FORGE.game, folder .. "/" .. script .. ".game") + authorSave(FORGE.game, folder .. "/" .. script .. ".forge") -- A games.dat so the menu lists it, written from what the description already knows. Names -- in it are as the engine sees them from the games directory, the folder's own name first, @@ -1304,6 +1356,7 @@ function forgeFilesNamed(game) end end + take(game.vocabulary) for _, layer in ipairs(game.layers or {}) do fromParams(layer, (AUTHOR.layers[layer.kind] or {}).params) end @@ -3323,7 +3376,7 @@ end function forgeCursorTo(at) FORGE.cursor = math.max(0, math.floor(at)) if FORGE.video then - videoSeek(FORGE.video, FORGE.cursor) + videoSeek(FORGE.video, math.max(0, FORGE.cursor - (FORGE.videoStart or 0))) end forgeRefresh() end @@ -3578,6 +3631,8 @@ function forgeFieldKind(name) param = key elseif (name == "reset") or (name == "depthSort") then return "flag" + elseif name == "vocabulary" then + return "file" end elseif FORGE.mode == "tracks" then local track = forgeRoom().tracks[FORGE.track] @@ -4220,7 +4275,7 @@ end -- The fields with nothing selected: the game's -- its title, players, verbs, and layers with -- what each layer takes, named layer.parameter as a behaviour's are -- and then the room's. function forgeRoomFieldNames() - local names = { "title", "players", "vars", "verbs", "layers" } + local names = { "title", "players", "vars", "verbs", "layers", "vocabulary" } for _, layer in ipairs(FORGE.game.layers or {}) do local entry = AUTHOR.layers[layer.kind] @@ -4263,6 +4318,8 @@ function forgeRoomGet(room, key) return table.concat(FORGE.game.verbs or {}, ", ") elseif key == "layers" then return listText(FORGE.game.layers) + elseif key == "vocabulary" then + return FORGE.game.vocabulary elseif group ~= nil then local layer = layerOf(group) @@ -4301,6 +4358,13 @@ function forgeRoomSet(room, key, value) if key == "title" then FORGE.game.title = tostring(value) + 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 + authorVocabularyForget() + if FORGE.game.vocabulary then + authorVocabulary(FORGE.game.vocabulary, string.match(FORGE.path or "", "^(.*[/\\])") or "") + end elseif key == "players" then if type(value) == "number" then FORGE.game.players = (value ~= 1) and math.floor(value) or nil @@ -4435,7 +4499,11 @@ function forgeEditCancel() if FORGE.editing == nil then return false end - FORGE.editing.set(FORGE.editing.key, FORGE.editing.was) + -- Only a value the typing changed is put back: a field passed over is left exactly as it + -- was, not written again (writing "" to a type's vars used to make it an empty table). + if FORGE.editing.changed then + FORGE.editing.set(FORGE.editing.key, FORGE.editing.was) + end FORGE.editing = nil FORGE.lastField = nil -- The next ENTER starts the form over rather than moving on. FORGE.message = "unchanged" @@ -4602,9 +4670,10 @@ end -- ===== Files ================================================================================== -- --- Descriptions live in Forge's data directory, which is the one place a packed Forge can write. --- Ones dropped into Forge's own directory are offered too, and opened as a copy in the data --- directory, since inside a .game they are read only. +-- Descriptions are .forge files -- Lua text; .game is a packed release, which the engine's menu +-- lists and Forge does not. They live in Forge's data directory, which is the one place a packed +-- Forge can write. Ones dropped into Forge's own directory are offered too, and opened as a copy +-- in the data directory, since inside a .game they are read only. -- A game to start from: ground, a hero that runs and jumps, and a readout. Enough to press play -- on, which is the point; everything in it can be changed or deleted. @@ -4637,22 +4706,7 @@ function forgeTemplate() end --- Whether a .game file is a packed game rather than a description: the database's own header. -local function packedGame(path) - local file = io.open(path, "rb") - local head - - if file == nil then - return false - end - head = file:read(15) - file:close() - - return head == "SQLite format 3" -end - - --- Every .game description in a directory, sorted. A directory that is not there lists nothing. +-- Every .forge description in a directory, sorted. A directory that is not there lists nothing. local function descriptionsIn(directory) local found = {} @@ -4660,10 +4714,8 @@ local function descriptionsIn(directory) return found end for name in lfs.dir(directory) do - -- A description is Lua text; a packed game is a database with the same extension, and one - -- released beside the descriptions is not offered as one. - if (string.sub(name, -5) == ".game") and not packedGame(directory .. name) then - found[#found + 1] = { name = string.sub(name, 1, -6), path = directory .. name } + if string.sub(name, -6) == ".forge" then + found[#found + 1] = { name = string.sub(name, 1, -7), path = directory .. name } end end table.sort(found, function(a, b) return a.name < b.name end) @@ -4696,14 +4748,14 @@ local function freshPath(stem) local base = singeGetDataPath() .. stem local n = 1 - if lfs.attributes(base .. ".game") == nil then - return base .. ".game" + if lfs.attributes(base .. ".forge") == nil then + return base .. ".forge" end - while lfs.attributes(base .. n .. ".game") ~= nil do + while lfs.attributes(base .. n .. ".forge") ~= nil do n = n + 1 end - return base .. n .. ".game" + return base .. n .. ".forge" end diff --git a/assets/Forge/tutorials/01-tenMinutes.game b/assets/Forge/tutorials/01-tenMinutes.forge similarity index 100% rename from assets/Forge/tutorials/01-tenMinutes.game rename to assets/Forge/tutorials/01-tenMinutes.forge diff --git a/assets/Forge/tutorials/02-platformer.game b/assets/Forge/tutorials/02-platformer.forge similarity index 100% rename from assets/Forge/tutorials/02-platformer.game rename to assets/Forge/tutorials/02-platformer.forge diff --git a/assets/Forge/tutorials/03-sprites.game b/assets/Forge/tutorials/03-sprites.forge similarity index 100% rename from assets/Forge/tutorials/03-sprites.game rename to assets/Forge/tutorials/03-sprites.forge diff --git a/assets/Forge/tutorials/04-shmup.game b/assets/Forge/tutorials/04-shmup.forge similarity index 100% rename from assets/Forge/tutorials/04-shmup.game rename to assets/Forge/tutorials/04-shmup.forge diff --git a/assets/Forge/tutorials/05-qte.game b/assets/Forge/tutorials/05-qte.forge similarity index 100% rename from assets/Forge/tutorials/05-qte.game rename to assets/Forge/tutorials/05-qte.forge diff --git a/assets/Forge/tutorials/06-gun.game b/assets/Forge/tutorials/06-gun.forge similarity index 100% rename from assets/Forge/tutorials/06-gun.game rename to assets/Forge/tutorials/06-gun.forge diff --git a/assets/Forge/tutorials/07-adventure.game b/assets/Forge/tutorials/07-adventure.forge similarity index 100% rename from assets/Forge/tutorials/07-adventure.game rename to assets/Forge/tutorials/07-adventure.forge diff --git a/assets/Forge/tutorials/08-scene.game b/assets/Forge/tutorials/08-scene.forge similarity index 100% rename from assets/Forge/tutorials/08-scene.game rename to assets/Forge/tutorials/08-scene.forge diff --git a/assets/Forge/tutorials/09-rail.game b/assets/Forge/tutorials/09-rail.forge similarity index 99% rename from assets/Forge/tutorials/09-rail.game rename to assets/Forge/tutorials/09-rail.forge index 0a33baf7f..feab26fb1 100644 --- a/assets/Forge/tutorials/09-rail.game +++ b/assets/Forge/tutorials/09-rail.forge @@ -1,5 +1,167 @@ -- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine. return { + layers = { + { fov = 60, kind = "scene3d" }, + { kind = "overlay" } + }, + rooms = { + { + entities = { + { id = "readout", type = "readout", x = 12, y = 12 }, + { id = "floor", type = "floor", x = 0.0, y = -0.1, z = -20.0 }, + { id = "painting", type = "painting", x = 0.0, y = 6.0, z = -30.0 }, + { id = "pillar", type = "pillar", x = 0.0, y = 4.0, z = -22.0 }, + { id = "camera", type = "camera", x = 0.0, y = 1.6, z = 0.0 }, + { id = "gun", type = "gun", x = 0.0, y = 0.0, z = 0.0 } + }, + name = "start", + tracks = { + { + key = "time", + name = "rail", + points = { + { + at = 0, + look = { 0, 1.2, -10 }, + x = 0, + y = 1.6, + z = 0 + }, + { + at = 4, + look = { 0, 1.2, -16 }, + stop = "gate", + x = 0, + y = 1.6, + z = -6 + }, + { + at = 10, + look = { 2, 1.2, -24 }, + stop = "pillar", + x = 2, + y = 1.6, + z = -14 + }, + { + at = 14, + look = { 2, 1.2, -28 }, + x = 2, + y = 1.6, + z = -18 + } + } + } + } + } + }, + rules = { + { + act = { + { "setText", entity = "readout", text = "\"score \" .. score .. \" health \" .. health .. \" ammo \" .. gun.ammo" } + }, + note = "The readout, every frame", + on = "frame" + }, + { + act = { + { "spawn", type = "zombie", x = -2, y = 0, z = -14 }, + { "spawn", type = "zombie", x = 2, y = 0, z = -15 } + }, + name = "gate", + note = "The gate: two come out of the dark", + on = "stopped", + when = {} + }, + { + act = { + { "spawn", type = "zombie", x = -1, y = 0, z = -23 }, + { "spawn", type = "zombie", x = 0, y = 0, z = -26 }, + { "spawn", type = "zombie", x = 4, y = 0, z = -24 } + }, + name = "pillar", + note = "The pillar: three more, one from behind it", + on = "stopped", + when = {} + }, + { + act = { + { "setState", entity = "self", state = "walk" } + }, + note = "A zombie walks the moment it is made", + on = "spawn", + type = "zombie", + when = {} + }, + { + act = { + { "damage", amount = 1, entity = "self" }, + { "addScore", amount = 100 }, + { "playSound", file = "Forge/kit/hit.wav" } + }, + note = "A shot lands", + on = "hit", + type = "zombie", + when = {} + }, + { + act = { + { "setState", entity = "self", state = "attack" }, + { "timerStart", after = 1.5, entity = "self", name = "bite" } + }, + note = "It reaches the camera and rears up", + on = "arrived", + type = "zombie", + when = {} + }, + { + act = { + { "addVar", amount = -1, name = "health" }, + { "flash", b = 40, g = 40, r = 255, seconds = 0.3 }, + { "shake", amount = 0.3, seconds = 0.4 }, + { "timerStart", after = 1.5, entity = "self", name = "bite" } + }, + name = "bite", + note = "The bite lands unless it was killed in time", + on = "timer", + type = "zombie", + when = { + { "inState", entity = "self", state = "attack" } + } + }, + { + act = { + { "pathNext", entity = "camera" } + }, + note = "The stop is clear: move on", + on = "frame", + when = { + { "waiting", entity = "camera" }, + { "test", expr = "count(\"zombie\") == 0" } + } + }, + { + act = { + { "say", seconds = 2, text = "\"YOU ARE DEAD\"" }, + { "gameOver" } + }, + note = "Out of health", + on = "frame", + when = { + { "test", expr = "health <= 0" }, + { "once", scope = "game", tag = "over" } + } + }, + { + act = { + { "say", seconds = 2, text = "\"STAGE CLEAR\"" } + }, + note = "The end of the rail", + on = "railEnd", + when = {} + } + }, + title = "The Dead Yard", types = { camera = { behaviours = { @@ -47,169 +209,5 @@ return { vars = { health = 2 } } }, - layers = { - { fov = 60, kind = "scene3d" }, - { kind = "overlay" } - }, - rooms = { - { - entities = { - { id = "readout", type = "readout", x = 12, y = 12 }, - { id = "floor", type = "floor", x = 0.0, y = -0.1, z = -20.0 }, - { id = "painting", type = "painting", x = 0.0, y = 6.0, z = -30.0 }, - { id = "pillar", type = "pillar", x = 0.0, y = 4.0, z = -22.0 }, - { id = "camera", type = "camera", x = 0.0, y = 1.6, z = 0.0 }, - { id = "gun", type = "gun", x = 0.0, y = 0.0, z = 0.0 } - }, - name = "start", - tracks = { - { - key = "time", - name = "rail", - points = { - { - at = 0, - look = { 0, 1.2, -10 }, - stop = "nil", - x = 0, - y = 1.6, - z = 0 - }, - { - at = 4, - look = { 0, 1.2, -16 }, - stop = "gate", - x = 0, - y = 1.6, - z = -6 - }, - { - at = 10, - look = { 2, 1.2, -24 }, - stop = "pillar", - x = 2, - y = 1.6, - z = -14 - }, - { - at = 14, - look = { 2, 1.2, -28 }, - stop = "nil", - x = 2, - y = 1.6, - z = -18 - } - } - } - } - } - }, - rules = { - { - act = { - { "setText", entity = "readout", text = "\"score \" .. score .. \" health \" .. health .. \" ammo \" .. gun.ammo" } - }, - note = "The readout, every frame", - on = "frame" - }, - { - act = { - { "spawn", type = "zombie", x = -2, y = 0, z = -14 }, - { "spawn", type = "zombie", x = 2, y = 0, z = -15 } - }, - name = "gate", - note = "The gate: two come out of the dark", - on = "stopped", - when = {} - }, - { - act = { - { "spawn", type = "zombie", x = -1, y = 0, z = -23 }, - { "spawn", type = "zombie", x = 0, y = 0, z = -26 }, - { "spawn", type = "zombie", x = 4, y = 0, z = -24 } - }, - name = "pillar", - note = "The pillar: three more, one from behind it", - on = "stopped", - when = {} - }, - { - act = { - { "setState", entity = "self", state = "walk" } - }, - type = "zombie", - note = "A zombie walks the moment it is made", - on = "spawn", - when = {} - }, - { - act = { - { "damage", amount = 1, entity = "self" }, - { "addScore", amount = 100 }, - { "playSound", file = "Forge/kit/hit.wav" } - }, - type = "zombie", - note = "A shot lands", - on = "hit", - when = {} - }, - { - act = { - { "setState", entity = "self", state = "attack" }, - { "timerStart", after = 1.5, entity = "self", name = "bite" } - }, - type = "zombie", - note = "It reaches the camera and rears up", - on = "arrived", - when = {} - }, - { - act = { - { "addVar", amount = -1, name = "health" }, - { "flash", b = 40, g = 40, r = 255, seconds = 0.3 }, - { "shake", amount = 0.3, seconds = 0.4 }, - { "timerStart", after = 1.5, entity = "self", name = "bite" } - }, - type = "zombie", - name = "bite", - note = "The bite lands unless it was killed in time", - on = "timer", - when = { - { "inState", entity = "self", state = "attack" } - } - }, - { - act = { - { "pathNext", entity = "camera" } - }, - note = "The stop is clear: move on", - on = "frame", - when = { - { "waiting", entity = "camera" }, - { "test", expr = "count(\"zombie\") == 0" } - } - }, - { - act = { - { "say", seconds = 2, text = "\"YOU ARE DEAD\"" }, - { "gameOver" } - }, - note = "Out of health", - on = "frame", - when = { - { "test", expr = "health <= 0" }, - { "once", scope = "game", tag = "over" } - } - }, - { - act = { - { "say", seconds = 2, text = "\"STAGE CLEAR\"" } - }, - note = "The end of the rail", - on = "railEnd", - when = {} - } - }, - title = "The Dead Yard", vars = { health = 3, score = 0 } } diff --git a/assets/Forge/tutorials/10-release.game b/assets/Forge/tutorials/10-release.forge similarity index 100% rename from assets/Forge/tutorials/10-release.game rename to assets/Forge/tutorials/10-release.forge diff --git a/assets/Menu.rml b/assets/Menu.rml index 2c811eb97..7bf84d350 100644 --- a/assets/Menu.rml +++ b/assets/Menu.rml @@ -32,10 +32,18 @@ </div> </div> <!-- Every service tool draws through this one page: Singe/Tools.singe hands the menu a list of - rows and Singe/MenuDocument.singe turns them into the divs below it. --> + rows and Singe/MenuDocument.singe turns them into the divs below it -- the rows into the + body, the key hints into the footer, and on the list of tools the chosen tool's note into + the column beside the list. --> <div id="tool" class="tool hidden"> - <h1 id="toolTitle"></h1> - <div id="toolBody"></div> + <div id="toolCard" class="card"> + <h1 id="toolTitle"></h1> + <div id="toolColumns" class="columns"> + <div id="toolBody"></div> + <div id="toolNote" class="hidden"></div> + </div> + <div id="toolKeys"></div> + </div> </div> </body> </rml> diff --git a/assets/MenuDocument.singe b/assets/MenuDocument.singe index 0b5fc1508..e4ff68f4a 100644 --- a/assets/MenuDocument.singe +++ b/assets/MenuDocument.singe @@ -177,7 +177,9 @@ local function toolRow(row) elseif row.kind == "keys" then return "<p class='keys'>" .. menuSpaces(row.text) .. "</p>" elseif row.kind == "item" then - return string.format("<div class='%s'>%s</div>", row.selected and "item selected" or "item", menuSpaces(row.text)) + local detail = row.detail and ("<span class='detail'>" .. menuSpaces(tostring(row.detail)) .. "</span>") or "" + + return string.format("<div class='%s'><span class='text'>%s</span>%s</div>", row.selected and "item selected" or "item", menuSpaces(row.text), detail) elseif row.kind == "blank" then return "<div class='blank'></div>" end @@ -377,6 +379,11 @@ MENU_RENDER.tools = function() -- of them quietly showing more. The style sheet can scroll this box, but nothing drives a -- scroll bar from a joystick: windowing is what actually keeps the chosen row on screen. local visible, more, below = toolsVisible(TOOL_ROWS, toolsCapacity()) + -- The key hints go to the card's footer and a note to the column beside the list; + -- everything else is the body. + local keys = {} + local note = {} + if more then rows[#rows + 1] = "<div class='sub'>...</div>" end @@ -390,13 +397,22 @@ MENU_RENDER.tools = function() if row.kind == "item" then seenItem = true end - rows[#rows + 1] = toolRow(row) + if row.kind == "keys" then + keys[#keys + 1] = toolRow(row) + elseif row.kind == "note" then + note[#note + 1] = toolRow(row) + else + rows[#rows + 1] = toolRow(row) + end end if below then rows[#rows + 1] = "<div class='sub'>...</div>" end guiSetValue(GUI, DOCUMENT, "toolTitle", menuEscape(TOOL_TITLE)) guiSetValue(GUI, DOCUMENT, "toolBody", table.concat(rows)) + guiSetValue(GUI, DOCUMENT, "toolNote", table.concat(note)) + guiSetValue(GUI, DOCUMENT, "toolKeys", table.concat(keys)) + menuElement("toolNote"):SetClass("hidden", #note == 0) menuElement("games"):SetClass("hidden", toolsActive()) menuElement("tool"):SetClass("hidden", not toolsActive()) menuElement("tool"):SetClass("clear", TOOL_CLEAR) diff --git a/assets/MenuOverlay.singe b/assets/MenuOverlay.singe index f31782ccb..1cc2464bc 100644 --- a/assets/MenuOverlay.singe +++ b/assets/MenuOverlay.singe @@ -36,8 +36,6 @@ -- whoever does have a keyboard. -local MARGIN_X = 25 -local MARGIN_Y = 25 local MARGIN = 10 -- Around the page, and between the regions on it. local GAP = 8 local HINT_H = 24 -- The line of key hints along the bottom. @@ -49,6 +47,24 @@ local VIDEO_ASPECT_H = 200 local LINE_HEIGHT = 24 local LABEL_W = 150 local SUB_INDENT = 20 +local CARD_PAD = 14 -- The card the tools draw on: inside its border to its text, +local TITLE_H = 36 -- its title bar, and its footer of key hints, one line each plus +local KEY_LINE = 18 -- its padding. The colours are the game page's: the panel, the +local KEY_PAD = 6 -- selection's gradient, the blue-grey border, the gold, the quiet +local BORDER = 2 -- blue-grey of a label. +local ITEM_INSET = 8 +local CLEAR_FRAC = 0.70 -- How wide a strip a page that lets the overlay through may take. +local NOTE_FRAC = 0.42 -- How much of the card a note beside the list takes. +local PANEL = { 24, 38, 64, 216 } +local PANEL_DEEP = { 12, 20, 36, 216 } +local FOOT = { 16, 24, 40, 208 } +local TITLE_BAR = { 51, 80, 122, 255 } +local TITLE_BAR_L = { 74, 110, 168, 255 } +local EDGE = { 106, 134, 184, 255 } +local GOLD = { 255, 211, 90, 255 } +local INK = { 240, 240, 240, 255 } +local QUIET = { 143, 162, 196, 255 } +local SOFT = { 200, 210, 230, 255 } local PAGE_ROWS = 10 local WRAP_BREAK = " [!wb!] " local HINTS = "Up / Down: select Left / Right: page PgUp / PgDn: details Start or button: play Service: tools" @@ -56,6 +72,7 @@ local HINTS = "Up / Down: select Left / Right: page PgUp / PgDn: det local font = nil local hintFont = nil -- Smaller: the hints are a footnote, and at the body's size the line -- of them ran off the end of a 720 pixel overlay. +local titleFont = nil -- Larger: a tool's title on its bar. local introDone = false local videoX, videoY, videoW, videoH = 0, 0, 0, 0 local marqueeX, marqueeY, marqueeW, marqueeH = 0, 0, 0, 0 @@ -146,6 +163,7 @@ end -- rather than values because the audio delay tool inverts the whole page for a frame, and fontPrint -- takes the colour at the moment it draws -- unlike fontToSprite, which bakes it in. local toolLines = {} +local bodyX, bodyW = 0, 0 -- The card's body, where wrapped text is measured against. local function toolLine(pieces) @@ -160,66 +178,120 @@ local function toolWrapped(text, x, role) toolLine({}) return end - for _, line in ipairs(fitLines(text, overlayGetWidth() - MARGIN_X - x)) do + for _, line in ipairs(fitLines(text, bodyX + bodyW - x)) do toolLine({ { x = x, text = line, role = role } }) end end +-- The card the tools draw on: its edges, and where its body and its columns begin. A page that +-- lets the overlay through has no card; its strip is measured once its lines are known. +local toolKeyLines = {} -- The key hints, for the card's footer. +local toolNoteLines = {} -- What the chosen tool is for, beside the list of tools. +local cardX, cardY, cardW, cardH = 0, 0, 0, 0 +local noteX, noteW = 0, 0 + + +local function toolNote(pieces) + toolNoteLines[#toolNoteLines + 1] = pieces +end + + local function toolsLayout() - local valueX = MARGIN_X + LABEL_W - local wasKeys = false + local width = overlayGetWidth() + local height = overlayGetHeight() -- Only the rows that fit, with the chosen one kept in view; a catalogue is as long as the -- service is big and would otherwise run off the bottom of the screen. local rows, more, below = toolsVisible(TOOL_ROWS, toolsCapacity()) - toolLines = {} + cardX, cardY, cardW, cardH = MARGIN, MARGIN, width - MARGIN * 2, height - MARGIN * 2 + if TOOL_CLEAR then + cardW = math.floor(width * CLEAR_FRAC) + cardX = math.floor((width - cardW) / 2) + end + bodyX = cardX + BORDER + CARD_PAD + bodyW = cardW - (BORDER + CARD_PAD) * 2 + local fullW = bodyW + -- A page with a note has a second column for it beside the list. + noteX, noteW = nil, 0 + for _, row in ipairs(rows) do + if row.kind == "note" then + noteW = math.floor(cardW * NOTE_FRAC) - (BORDER + CARD_PAD) * 2 + noteX = cardX + cardW - BORDER - CARD_PAD - noteW + bodyW = noteX - CARD_PAD - BORDER - bodyX + end + end + local valueX = bodyX + LABEL_W + + toolLines = {} + toolKeyLines = {} + toolNoteLines = {} if more then - toolLine({ { x = MARGIN_X, text = " ...", role = "quiet" } }) + toolLine({ { x = bodyX, text = " ...", role = "quiet" } }) end local seenItem = false for _, row in ipairs(rows) do -- The "more below" marker belongs immediately under the last item, not at the very bottom -- of the page under the key hints, which are not part of the list. if below and seenItem and row.kind ~= "item" then - toolLine({ { x = MARGIN_X, text = " ...", role = "quiet" } }) + toolLine({ { x = bodyX, text = " ...", role = "quiet" } }) below = false end if row.kind == "item" then seenItem = true end - -- The key hints stand away from whatever the tool put above them, as they do in the - -- document, but sit together when a tool needs more than one line of them. - if row.kind == "keys" and not wasKeys then - toolLine({}) - end - wasKeys = (row.kind == "keys") - if row.kind == "blank" then toolLine({}) elseif row.kind == "pair" then -- The label and the first line of the value share a line; the rest of the value wraps -- under itself, in its own column. - local lines = fitLines(row.value, overlayGetWidth() - MARGIN_X - valueX) - toolLine({ { x = MARGIN_X, text = row.label, role = "quiet" }, - { x = valueX, text = lines[1] or "", role = "ink" } }) + local lines = fitLines(row.value, bodyX + bodyW - valueX) + toolLine({ { x = bodyX, text = row.label, role = "quiet" }, + { x = valueX, text = lines[1] or "", role = "ink" } }) for index = 2, #lines do toolLine({ { x = valueX, text = lines[index], role = "ink" } }) end elseif row.kind == "sub" then - toolWrapped(row.text, MARGIN_X + SUB_INDENT, "quiet") + for _, line in ipairs(fitLines(row.text, bodyW - SUB_INDENT)) do + toolLine({ { x = bodyX + SUB_INDENT, text = line, role = "soft" } }) + end elseif row.kind == "keys" then - toolWrapped(row.text, MARGIN_X, "quiet") + -- The footer, in the smaller font, across the whole card. + fontSelect(hintFont) + for _, line in ipairs(fitLines(row.text, fullW)) do + toolKeyLines[#toolKeyLines + 1] = line + end + fontSelect(font) elseif row.kind == "item" then - toolLine({ { x = MARGIN_X, text = (row.selected and "> " or " ") .. row.text, - role = row.selected and "pick" or "ink" } }) + local pieces = { { x = bodyX + ITEM_INSET, text = row.text, role = row.selected and "pick" or "ink", bar = row.selected } } + + -- The detail against the right edge of the row, so a column of them lines up. An + -- empty one -- a field not filled in yet -- is nothing to measure or draw. + local detail = row.detail and tostring(row.detail) or "" + + if detail ~= "" then + local measured = fontToSprite(detail) + + pieces[#pieces + 1] = { x = bodyX + bodyW - ITEM_INSET - spriteGetWidth(measured), text = detail, role = pieces[1].role } + spriteUnload(measured) + end + toolLine(pieces) + elseif row.kind == "note" then + for _, line in ipairs(fitLines(row.text, noteW)) do + toolNote({ { x = noteX, text = line, role = "soft" } }) + end else - toolWrapped(row.text, MARGIN_X, "ink") + toolWrapped(row.text, bodyX, "ink") end end -- A list that runs to the very end of the page has no row after it to hang the marker on. if below then - toolLine({ { x = MARGIN_X, text = " ...", role = "quiet" } }) + toolLine({ { x = bodyX, text = " ...", role = "quiet" } }) + end + -- A strip along the bottom is as tall as its lines. + if TOOL_CLEAR then + cardH = BORDER * 2 + TITLE_H + CARD_PAD + #toolLines * LINE_HEIGHT + CARD_PAD + KEY_PAD * 2 + #toolKeyLines * KEY_LINE + cardY = height - MARGIN - cardH end end @@ -228,22 +300,21 @@ MENU_RENDER = {} MENU_RENDER.begin = function() - font = fontLoad("Singe/FreeSansBold.ttf", 18) - hintFont = fontLoad("Singe/FreeSansBold.ttf", 13) + font = fontLoad("Singe/FreeSansBold.ttf", 18) + hintFont = fontLoad("Singe/FreeSansBold.ttf", 13) + titleFont = fontLoad("Singe/FreeSansBold.ttf", 22) fontQuality(FONT_QUALITY_BLENDED) fontSelect(font) end MENU_RENDER.finish = function() - if font then - fontUnload(font) - font = nil - end - if hintFont then - fontUnload(hintFont) - hintFont = nil + for _, handle in ipairs({ font, hintFont, titleFont }) do + if handle then + fontUnload(handle) + end end + font, hintFont, titleFont = nil, nil, nil end @@ -489,17 +560,45 @@ MENU_RENDER.frame = function() end -MENU_RENDER.tools = function() - local y = MARGIN_Y +-- A filled rectangle: the overlay has no fill of its own, so it is lines, one a row. The colour +-- carries its alpha, which is what makes the card a panel over the moving grid and not a slab. +-- Given a second colour it runs from the first at the top to the second at the bottom: every +-- row is its own line already, so the gradient costs nothing more. +local function fillRect(x, y, w, h, colour, toColour) + local last = math.max(1, h - 1) - -- A tool that draws its own picture into the overlay has already done so, and the page has to - -- let it through; every other tool gets a page painted over everything. - if not TOOL_CLEAR then - if TOOL_FLASH then - colorBackground(255, 255, 255, 255) - else - colorBackground(0, 0, 0, 255) + for row = 0, h - 1 do + if toColour then + local t = row / last + + colorForeground(math.floor(colour[1] + (toColour[1] - colour[1]) * t + 0.5), + math.floor(colour[2] + (toColour[2] - colour[2]) * t + 0.5), + math.floor(colour[3] + (toColour[3] - colour[3]) * t + 0.5), + math.floor(colour[4] + (toColour[4] - colour[4]) * t + 0.5)) + elseif row == 0 then + colorForeground(colour[1], colour[2], colour[3], colour[4]) end + overlayLine(x, y + row, x + w - 1, y + row) + end +end + + +local function ink(colour) + colorForeground(colour[1], colour[2], colour[3], colour[4]) +end + + +-- The page: the card with its title bar, the rows in their columns, the chosen row on its bar, +-- and the key hints in the footer. A page that draws its own picture gets a strip along the +-- bottom instead; the audio delay tool's flash is the whole screen white and nothing else. +MENU_RENDER.tools = function() + if TOOL_FLASH then + colorBackground(255, 255, 255, 255) + overlayClear() + return + end + if not TOOL_CLEAR then + colorBackground(0, 0, 0, 0) overlayClear() end @@ -509,29 +608,43 @@ MENU_RENDER.tools = function() toolsLayout() end - local ink = TOOL_FLASH and 0 or 255 - local quiet = TOOL_FLASH and 90 or 160 + local footH = KEY_PAD * 2 + #toolKeyLines * KEY_LINE + local footY = cardY + cardH - BORDER - footH + local y = cardY + BORDER + TITLE_H + CARD_PAD - -- A page that lets the overlay through gathers its text along the bottom, out of the way of - -- whatever the tool drew: the light gun's corner targets in particular. - if TOOL_CLEAR then - y = overlayGetHeight() - MARGIN_Y - (#toolLines + 1) * LINE_HEIGHT + -- The card: the panel, its border twice for the width, the title bar, and the footer. + fillRect(cardX, cardY, cardW, cardH, PANEL, PANEL_DEEP) + fillRect(cardX + BORDER, cardY + BORDER, cardW - BORDER * 2, TITLE_H, TITLE_BAR_L, TITLE_BAR) + fillRect(cardX + BORDER, cardY + BORDER + TITLE_H, cardW - BORDER * 2, BORDER, EDGE) + if #toolKeyLines > 0 then + fillRect(cardX + BORDER, footY, cardW - BORDER * 2, footH, FOOT) + fillRect(cardX + BORDER, footY - BORDER, cardW - BORDER * 2, BORDER, EDGE) end - colorForeground(ink, ink, ink, 255) - fontPrint(MARGIN_X, y, TOOL_TITLE) - y = y + LINE_HEIGHT - if not TOOL_CLEAR then - y = y + LINE_HEIGHT + ink(EDGE) + overlayBox(cardX, cardY, cardX + cardW - 1, cardY + cardH - 1) + overlayBox(cardX + 1, cardY + 1, cardX + cardW - 2, cardY + cardH - 2) + if noteX then + fillRect(noteX - CARD_PAD - BORDER, cardY + BORDER + TITLE_H + BORDER, BORDER, footY - BORDER - (cardY + BORDER + TITLE_H + BORDER), EDGE) end + fontSelect(titleFont) + ink(GOLD) + fontPrint(cardX + BORDER + CARD_PAD, cardY + BORDER + math.floor((TITLE_H - 26) / 2), TOOL_TITLE) + fontSelect(font) + for _, line in ipairs(toolLines) do for _, piece in ipairs(line) do + if piece.bar then + fillRect(bodyX, y - 3, bodyW, LINE_HEIGHT - 2, TITLE_BAR_L, TITLE_BAR) + end if piece.role == "quiet" then - colorForeground(quiet, quiet, quiet, 255) + ink(QUIET) + elseif piece.role == "soft" then + ink(SOFT) elseif piece.role == "pick" then - colorForeground(255, 211, 90, 255) + ink(GOLD) else - colorForeground(ink, ink, ink, 255) + ink(INK) end -- fontPrint refuses an empty string, and a tool legitimately produces one: a status -- line with nothing to say yet is still a line, and it still takes up its height. @@ -541,4 +654,25 @@ MENU_RENDER.tools = function() end y = y + LINE_HEIGHT end + y = cardY + BORDER + TITLE_H + CARD_PAD + for _, line in ipairs(toolNoteLines) do + for _, piece in ipairs(line) do + ink(SOFT) + if piece.text ~= "" then + fontPrint(piece.x, y, piece.text) + end + end + y = y + LINE_HEIGHT + end + + fontSelect(hintFont) + ink(QUIET) + y = footY + KEY_PAD + for _, line in ipairs(toolKeyLines) do + if line ~= "" then + fontPrint(bodyX, y, line) + end + y = y + KEY_LINE + end + fontSelect(font) end diff --git a/assets/Tools.singe b/assets/Tools.singe index 85734520e..77b459dcf 100644 --- a/assets/Tools.singe +++ b/assets/Tools.singe @@ -28,7 +28,7 @@ -- tool leaves out simply does not happen. -- -- Nothing here draws. A tool builds a list of rows with the toolsText, toolsPair, toolsSub, --- toolsKeys, toolsItem and toolsBlank constructors and hands it to toolsShow; the menu that loaded +-- toolsKeys, toolsNote, toolsItem, and toolsBlank constructors and hands it to toolsShow; the menu that loaded -- this file draws it whichever way it draws everything else. That is what lets the same ten tools -- run over the RmlUi document and over the plain overlay on a machine with no GPU. @@ -78,8 +78,17 @@ function toolsKeys(text) end -function toolsItem(text, selected) - return { kind = "item", text = text, selected = selected } +-- A note beside the page's list rather than under it: what the chosen tool is for. +function toolsNote(text) + return { kind = "note", text = text } +end + + +-- A line to choose between, with an optional detail the menu sets against the right edge of the +-- row -- a size, a state, a value. Never padded into the text with spaces: the menu's fonts are +-- proportional and nothing lines up that way. +function toolsItem(text, selected, detail) + return { kind = "item", text = text, selected = selected, detail = detail } end @@ -96,11 +105,11 @@ function toolsShow(rows) end --- How many rows of a page fit on screen. +-- How many rows of a page's body fit on screen. -- -- Derived from the overlay, which is the size both renderers draw into, so the two show the same --- thing rather than one of them quietly showing more. The subtraction is the title and the couple --- of lines of key hints that live below every list. +-- thing rather than one of them quietly showing more. The subtraction is the card around the +-- body: its margins, its title bar, and the footer of key hints under every page. function toolsCapacity() return math.max(4, math.floor(overlayGetHeight() / TOOL_LINE_HEIGHT) - TOOL_CHROME_LINES) end @@ -126,14 +135,21 @@ function toolsVisible(rows, capacity) end end end + -- The key hints and a note are drawn beside the body, not in it, so they take none of its room. + local body = 0 + for _, row in ipairs(rows) do + if row.kind ~= "keys" and row.kind ~= "note" then + body = body + 1 + end + end -- Nothing to scroll: a page with no list, or one whose list already fits. - if #items == 0 or #rows <= capacity then + if #items == 0 or body <= capacity then return rows, false, false end -- Keep the chosen row in the middle of the window where there is room either side of it, so -- moving down does not pin it to the last line. - local room = math.max(1, capacity - (#rows - #items)) + local room = math.max(1, capacity - (body - #items)) local first = math.max(1, math.min((chosen or 1) - math.floor(room / 2), #items - room + 1)) local last = math.min(#items, first + room - 1) @@ -191,8 +207,7 @@ function toolsShowList() for i, tool in ipairs(TOOLS) do rows[#rows + 1] = toolsItem(tool.name, i == TOOL_SELECTED) end - rows[#rows + 1] = toolsBlank() - rows[#rows + 1] = toolsText(TOOLS[TOOL_SELECTED].help or "") + rows[#rows + 1] = toolsNote(TOOLS[TOOL_SELECTED].help or "") rows[#rows + 1] = toolsKeys("Up / Down: choose Button 1: open Service: back to the games") toolsShow(rows) end @@ -414,7 +429,8 @@ TOOLS[#TOOLS + 1] = { update = function() local rows = {} - rows[#rows + 1] = toolsPair("Last switch", string.format("%s count: %d", inputLast, inputCount)) + rows[#rows + 1] = toolsPair("Last switch", inputLast) + rows[#rows + 1] = toolsPair("Count", inputCount) rows[#rows + 1] = toolsPair("Devices", string.format("%d gamepads, %d mice, stick drives mouse: %s", controllerHowMany(), mouseHowMany(), tostring(joyMouseIsEnabled()))) for slot = 0, SINGE_MAX_CONTROLLERS - 1 do @@ -432,8 +448,8 @@ TOOLS[#TOOLS + 1] = { end end rows[#rows + 1] = toolsText(string.format("%d: %s", slot, controllerGetName(slot))) - rows[#rows + 1] = toolsSub(string.format("axes %s buttons %s", - table.concat(axes, ", "), (#buttons > 0) and table.concat(buttons, " ") or "-")) + rows[#rows + 1] = toolsSub("axes " .. table.concat(axes, ", ")) + rows[#rows + 1] = toolsSub("buttons " .. ((#buttons > 0) and table.concat(buttons, " ") or "-")) end end for m = 0, mouseHowMany() - 1 do @@ -494,7 +510,8 @@ local gunLast = "none yet" local function gunShow() toolsShow({ - toolsPair("Last shot", string.format("%s shots shown: %d", gunLast, #gunShots)), + toolsPair("Last shot", gunLast), + toolsPair("Shots shown", #gunShots), toolsKeys("Button 1: fire Start: clear Button 2: back"), }) end @@ -642,8 +659,9 @@ local dispSaved = "" local function displayShow() local x, y = vldpGetShift() toolsShow({ - toolsPair("Picture", string.format("scale %d%% shift %d, %d rotation %d", - vldpGetScale(), x, y, vldpGetRotate())), + toolsPair("Scale", vldpGetScale() .. "%"), + toolsPair("Shift", string.format("%d, %d", x, y)), + toolsPair("Rotation", vldpGetRotate()), toolsPair("Kept", dispSaved), toolsKeys("Arrows: move Button 3 / 4: size Coin 1: rotate Start: reset"), toolsKeys("Button 1: keep for this machine Button 2: back"), @@ -833,7 +851,7 @@ local function savedShow() rows[#rows + 1] = toolsText("No game has saved anything yet.") end for i, entry in ipairs(saveDirs) do - rows[#rows + 1] = toolsItem(string.format("%s %d bytes", entry.name, entry.size), i == saveSelected) + rows[#rows + 1] = toolsItem(entry.name, i == saveSelected, entry.size .. " bytes") end rows[#rows + 1] = toolsKeys("Up / Down: choose Button 3: delete this game's save Button 2: back") toolsShow(rows) @@ -912,11 +930,12 @@ local function midiShow() local rows = {} rows[#rows + 1] = toolsPair("Ports", string.format("%d in, %d out", midiInputCount(), midiOutputCount())) + -- The port in use is starred. for i = 0, midiOutputCount() - 1 do - rows[#rows + 1] = toolsSub(string.format("out %d%s %s", i, (i == midiOut) and " *" or "", midiOutputName(i))) + rows[#rows + 1] = toolsPair(string.format("Out %d%s", i, (i == midiOut) and " *" or ""), midiOutputName(i)) end for i = 0, midiInputCount() - 1 do - rows[#rows + 1] = toolsSub(string.format("in %d%s %s", i, (i == midiIn) and " *" or "", midiInputName(i))) + rows[#rows + 1] = toolsPair(string.format("In %d%s", i, (i == midiIn) and " *" or ""), midiInputName(i)) end for _, m in ipairs(midiSeen) do rows[#rows + 1] = toolsSub("received " .. m) @@ -1042,7 +1061,7 @@ local function accountShow() if name == "server" and shown == "" then shown = MASTER.url end - rows[#rows + 1] = toolsItem(string.format("%-9s %s", name, shown), index == accountField) + rows[#rows + 1] = toolsItem(name, index == accountField, shown) end rows[#rows + 1] = toolsBlank() rows[#rows + 1] = toolsText(accountSaying) @@ -1198,7 +1217,7 @@ local function shopShow() elseif held ~= nil then state = (held >= game.version) and "installed" or ("update to " .. game.version) end - rows[#rows + 1] = toolsItem(string.format("%-28s %s", game.title, state), index == shopSelected) + rows[#rows + 1] = toolsItem(game.title, index == shopSelected, state) end if #shopGames == 0 then rows[#rows + 1] = toolsText("Nothing listed yet.") diff --git a/assets/menu.rcss b/assets/menu.rcss index 7e997b030..7555c2d15 100644 --- a/assets/menu.rcss +++ b/assets/menu.rcss @@ -123,8 +123,10 @@ body { display: none; } -/* The service tools: one opaque page over everything, the same for all of them. The audio delay - tool alone turns the page white for the frame of its flash, so the colours are named twice. */ +/* The service tools: one page over everything, the backdrop still moving behind it, and on it + a card in the game page's own dress -- the panel gradient, the blue-grey border, a title bar in + the selection's gradient with the title in the game list's gold, and the key hints in a footer + strip like the game page's. Nothing on it says black. */ .tool { position: absolute; left: 0; @@ -132,60 +134,107 @@ body { width: 100%; height: 100%; z-index: 1; - padding: 25dp; + padding: 10dp; box-sizing: border-box; - background-color: #000000; - color: #ffffff; - overflow-y: auto; + display: flex; + flex-direction: column; + color: #f0f0f0; + font-size: 15dp; } +.tool .card { + flex: 1 1 0; + display: flex; + flex-direction: column; + background-color: #0a1220d8; + decorator: linear-gradient(180deg, #182640d8, #0c1424d8); + border: 2dp #6a86b8; + border-radius: 6dp; + box-shadow: 0 6dp 14dp #00000090; + overflow: hidden; +} + +/* The audio delay tool alone turns the whole screen white for the frame of its flash; the card + steps out of the way so the flash is the flash and nothing else. */ .tool.flash { background-color: #ffffff; - color: #000000; +} + +.tool.flash .card { + visibility: hidden; } .tool h1 { - font-size: 24dp; - color: #ffffff; - margin-bottom: 20dp; + font-size: 20dp; + color: #ffd35a; + margin: 0; + padding: 8dp 14dp; + background-color: #33507a; + decorator: linear-gradient(90deg, #4a6ea8, #33507a); + border-bottom: 2dp #6a86b8; } -.tool.flash h1 { - color: #000000; +.tool .columns { + flex: 1 1 0; + display: flex; + flex-direction: row; + overflow: hidden; +} + +/* The style sheet can scroll this box, but nothing drives a scroll bar from a joystick: the + windowing in Singe/Tools.singe is what keeps the chosen row on screen. */ +.tool #toolBody { + flex: 1 1 0; + padding: 12dp 14dp; + overflow-y: auto; +} + +/* The list of tools has a second column: what the chosen tool is for, beside the list. */ +.tool #toolNote { + flex: 0 0 42%; + padding: 12dp 16dp; + border-left: 2dp #6a86b8; + color: #c8d2e6; + font-size: 14dp; +} + +.tool #toolNote.hidden { + display: none; +} + +.tool #toolKeys { + padding: 6dp 14dp; + border-top: 2dp #6a86b8; + background-color: #101828d0; + color: #8fa2c4; + font-size: 13dp; } .tool p { - margin-bottom: 20dp; + margin: 0; } -.tool p.value { - margin-bottom: 4dp; -} - -/* The key hints stand away from whatever the tool put above them, and close together among - themselves when a tool needs more than one line of them. */ +/* The key hints, close together when a tool needs more than one line of them. */ .tool p.keys { - margin-top: 20dp; - margin-bottom: 4dp; - color: #8fa2c4; + margin: 0; } .tool p.keys + p.keys { - margin-top: 0; + margin-top: 2dp; } /* The rows a tool fills in for itself: a heading row and an indented detail row under it. Text with nowhere to break -- a long path -- wraps mid-word rather than running off the edge, but an ordinary word is left whole. */ .tool .row { - margin-bottom: 2dp; + margin-bottom: 4dp; word-break: break-word; } .tool .sub { margin-left: 20dp; - margin-bottom: 2dp; - font-size: 12dp; + margin-bottom: 3dp; + font-size: 13dp; color: #c8d2e6; word-break: break-word; } @@ -194,11 +243,11 @@ body { for the line wraps under itself instead of under the label. */ .tool .pair { display: flex; - margin-bottom: 2dp; + margin-bottom: 4dp; } .tool .pair .label { - flex: 0 0 110dp; + flex: 0 0 130dp; color: #8fa2c4; } @@ -207,13 +256,29 @@ body { word-break: break-word; } -/* A line a tool offers to choose between, and the chosen one in the game list's gold -- the same - way the game list itself shows a selection, since RCSS has no generated content to mark it with. */ +/* A line a tool offers to choose between, and the chosen one the way the game list shows its + selection: gold on the gradient bar. A detail the row carries -- a size, a state, a value -- + sits against the right edge, so a column of them lines up whatever the text beside it. */ .tool .item { - margin-bottom: 2dp; + display: flex; + padding: 3dp 8dp; + margin: 0 0 1dp -8dp; + border-radius: 3dp; +} + +.tool .item .text { + flex: 1 1 0; +} + +.tool .item .detail { + flex: 0 0 auto; + margin-left: 16dp; + text-align: right; } .tool .item.selected { + background-color: #33507a; + decorator: linear-gradient(90deg, #4a6ea8, #33507a); color: #ffd35a; } @@ -223,31 +288,35 @@ body { } /* Two tools draw their own picture into the overlay, which is underneath the document: the light - gun's targets and the display's test pattern. Those pages let it through and gather their text - into a strip along the bottom, between the two lower targets, so nothing worth aiming at is - covered. */ -.tool.clear { - background-color: transparent; - display: flex; - flex-direction: column; - justify-content: flex-end; - align-items: center; + gun's targets and the display's test pattern. Those pages let it through, and the card becomes + a strip along the bottom between the two lower targets, so nothing worth aiming at is covered. + Its width is fixed: left to fit its content, the value column of a pair had no room and broke + its words one letter to a line. */ +.tool.clear .card { + flex: 0 0 auto; + margin-top: auto; + align-self: center; + width: 70%; } .tool.clear h1 { font-size: 16dp; - margin-bottom: 4dp; + padding: 4dp 12dp; } -.tool.clear h1, -.tool.clear .row, -.tool.clear .pair, -.tool.clear .sub, -.tool.clear .item, -.tool.clear p { - background-color: #000000c0; - padding: 1dp 8dp; - margin-bottom: 4dp; +/* The strip is as tall as its rows: the columns, which fill the page otherwise, take what they + need here. The body inside them keeps its flex, which in their row is its width; without it + a pair's value column had no room. */ +.tool.clear .columns { + flex: 0 0 auto; +} + +.tool.clear #toolBody { + padding: 6dp 12dp; +} + +.tool.clear #toolKeys { + padding: 4dp 12dp; } /* The help under the list of tools, away from the list itself. */ diff --git a/assets/menuBackground.mkv b/assets/menuBackground.mkv index c4a7eb7a9..3044f6648 100644 --- a/assets/menuBackground.mkv +++ b/assets/menuBackground.mkv @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8168dd1d1a3aa5d2ea18a50e71f2e2dc57be4eec122c083440599179a57fec61 -size 924469 +oid sha256:c943df76b0b52756c56528c83c028cb62eece8934aa2fa592a05bb791c3f1c30 +size 928159 diff --git a/assets/menuIntro.flac b/assets/menuIntro.flac index 57d574623..c5fa6c36b 100644 --- a/assets/menuIntro.flac +++ b/assets/menuIntro.flac @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3aa7de4741a9b8ad9caa942fc9c9c858562ea7ef2e0da7d088d1bdb5a0241621 -size 1101803 +oid sha256:6845ba02a214a56b5d6f4805679dcdde8e610ec054dc1834521a88e94ba3bd27 +size 1007690 diff --git a/assets/samples/dragonFireBreathAndRoar.flac b/assets/samples/dragonFireBreathAndRoar.flac new file mode 100644 index 000000000..6a73dedc5 --- /dev/null +++ b/assets/samples/dragonFireBreathAndRoar.flac @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa9cd590b39bc257718047f93453ccaae4128d27f83106f8bd62c5ab16083d9b +size 927467 diff --git a/assets/samples/explosionUltraBass.flac b/assets/samples/explosionUltraBass.flac new file mode 100644 index 000000000..9037ae3d8 --- /dev/null +++ b/assets/samples/explosionUltraBass.flac @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c8d9906fb4b77c357087f01dd7df5aadac31a0bc6d8849c33e2c3946efd2569 +size 776949 diff --git a/assets/samples/largeFireball.flac b/assets/samples/largeFireball.flac new file mode 100644 index 000000000..cb4eaab6e --- /dev/null +++ b/assets/samples/largeFireball.flac @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a91ad4d9fe96a2bc71996a28940704aea2f4ced456edf48bbb365a529b9ed0cd +size 108817 diff --git a/cmake/Superbuild.cmake b/cmake/Superbuild.cmake index 820c79c55..ec1b95943 100644 --- a/cmake/Superbuild.cmake +++ b/cmake/Superbuild.cmake @@ -476,10 +476,10 @@ if((KANGAROO_OS STREQUAL "linux") AND (CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux")) COMMAND ${CMAKE_COMMAND} -E copy ${forgeManual} ${forgeStage}/Forge/Forge.pdf # The samples that need no art beyond what the engine ships, so the chooser has something to # open on a fresh install. They come from the test set, which is the one copy of them. - COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_SOURCE_DIR}/testScripts/author/platformer.game ${CMAKE_SOURCE_DIR}/testScripts/author/breakout.game ${CMAKE_SOURCE_DIR}/testScripts/author/qte.game ${CMAKE_SOURCE_DIR}/testScripts/author/fmv.game ${forgeStage}/Forge/ + COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_SOURCE_DIR}/testScripts/author/platformer.forge ${CMAKE_SOURCE_DIR}/testScripts/author/breakout.forge ${CMAKE_SOURCE_DIR}/testScripts/author/qte.forge ${CMAKE_SOURCE_DIR}/testScripts/author/fmv.forge ${forgeStage}/Forge/ # Run it somewhere harmless: the engine writes Singe/ and data/ into the working directory. COMMAND ${CMAKE_COMMAND} -E chdir ${forgeStage} ${CMAKE_SOURCE_DIR}/.builddir/${singeBinaryName} --pack ${forgeStage}/Forge ${forgePack} - DEPENDS singe ${forgeSources} ${forgeManual} ${CMAKE_SOURCE_DIR}/testScripts/author/platformer.game ${CMAKE_SOURCE_DIR}/testScripts/author/breakout.game ${CMAKE_SOURCE_DIR}/testScripts/author/qte.game ${CMAKE_SOURCE_DIR}/testScripts/author/fmv.game + DEPENDS singe ${forgeSources} ${forgeManual} ${CMAKE_SOURCE_DIR}/testScripts/author/platformer.forge ${CMAKE_SOURCE_DIR}/testScripts/author/breakout.forge ${CMAKE_SOURCE_DIR}/testScripts/author/qte.forge ${CMAKE_SOURCE_DIR}/testScripts/author/fmv.forge COMMENT "Packing Forge into .builddir/Forge.game" VERBATIM ) diff --git a/docs/Forge.adoc b/docs/Forge.adoc index 25687cead..4d9ba1336 100644 --- a/docs/Forge.adoc +++ b/docs/Forge.adoc @@ -121,12 +121,14 @@ types and the rest are explained in * Give the ground a colour of its own. * Drag the hero somewhere else with the mouse, and play again. -**The description.** What kbd:[S] wrote, in full. `Forge/tutorials/01-tenMinutes.game` +**The description.** What kbd:[S] wrote, in full. A description is a +`.forge` file -- Lua text -- and never a `.game`, which is a packed release +the Singe menu lists. `Forge/tutorials/01-tenMinutes.forge` in the chooser is this file. [source,lua] ---- -include::../assets/Forge/tutorials/01-tenMinutes.game[] +include::../assets/Forge/tutorials/01-tenMinutes.forge[] ---- === Tutorial 2: A platformer @@ -227,7 +229,7 @@ is the three types, their entities, the three rules, and the second room. [source,lua] ---- -include::../assets/Forge/tutorials/02-platformer.game[] +include::../assets/Forge/tutorials/02-platformer.forge[] ---- === Tutorial 3: Sprites and sound @@ -307,7 +309,7 @@ states are in <<_instances_vars_and_states>> and [source,lua] ---- -include::../assets/Forge/tutorials/03-sprites.game[] +include::../assets/Forge/tutorials/03-sprites.forge[] ---- === Tutorial 4: A shoot-em-up @@ -408,7 +410,7 @@ runs once per instance of a type, with that instance as `self`, and a [source,lua] ---- -include::../assets/Forge/tutorials/04-shmup.game[] +include::../assets/Forge/tutorials/04-shmup.forge[] ---- === Tutorial 5: A quick-time event @@ -476,7 +478,7 @@ keeps a rule from firing every frame the window is open, and `keyPressed` is the frame a key goes down, where `keyHeld` is every frame it is down. `setVar` and `test` on a var of your own are how a rule remembers what another did. The `branching` behaviour does all of this in one track for a -game of many branches; `Forge/fmv.game` in the chooser is one. In a +game of many branches; `Forge/fmv.forge` in the chooser is one. In a released game, `games.dat` names the video, as it does for any Singe game. **Try this.** @@ -489,7 +491,7 @@ released game, `games.dat` names the video, as it does for any Singe game. [source,lua] ---- -include::../assets/Forge/tutorials/05-qte.game[] +include::../assets/Forge/tutorials/05-qte.forge[] ---- === Tutorial 6: A light gun game @@ -575,7 +577,7 @@ with a light gun needs -- calibration, two guns, the recoil -- is in [source,lua] ---- -include::../assets/Forge/tutorials/06-gun.game[] +include::../assets/Forge/tutorials/06-gun.forge[] ---- === Tutorial 7: A point-and-click adventure @@ -702,7 +704,7 @@ off. All of it is in [source,lua] ---- -include::../assets/Forge/tutorials/07-adventure.game[] +include::../assets/Forge/tutorials/07-adventure.forge[] ---- === Tutorial 8: Into 3D @@ -797,7 +799,7 @@ viewport in <<_the_3d_viewport>>. [source,lua] ---- -include::../assets/Forge/tutorials/08-scene.game[] +include::../assets/Forge/tutorials/08-scene.forge[] ---- === Tutorial 9: A rail shooter @@ -916,7 +918,7 @@ in <<_games_in_the_scene>>; painted rooms in [source,lua] ---- -include::../assets/Forge/tutorials/09-rail.game[] +include::../assets/Forge/tutorials/09-rail.forge[] ---- === Tutorial 10: Releasing your game @@ -956,7 +958,7 @@ image::tutorials/10-arcadeRules.png[The arcade rules, 600] . kbd:[S], then kbd:[X]: the game is *released* into a folder named for it in Forge's data directory -- `CoinRun` -- and the message says where. In it: `CoinRun.singe`, the compiled game; `Author.singe`, the runtime - it needs; `CoinRun.game`, the description, so it can be opened again; + it needs; `CoinRun.forge`, the description, so it can be opened again; `games.dat`, so the menu lists it, naming the script as the menu will see it, `CoinRun/CoinRun.singe`; and every file the description named, at the same relative name -- here the kit's hero, coins, and sounds under @@ -989,7 +991,7 @@ is what a cabinet's coin door shows. Releasing is in **Try this.** -* Open `CoinRun.game` from the release folder in Forge -- drop it in the +* Open `CoinRun.forge` from the release folder in Forge -- drop it in the chooser's data directory -- change something, and release again. * Two players: `players` `2`, a second hero on the other keys, and `score2` and `lives2` for the bezel's other side. @@ -999,7 +1001,7 @@ is what a cabinet's coin door shows. Releasing is in [source,lua] ---- -include::../assets/Forge/tutorials/10-release.game[] +include::../assets/Forge/tutorials/10-release.forge[] ---- === Recipes @@ -1008,7 +1010,7 @@ The rest of the catalogue, a paragraph each, with a sample to open from the chooser. A recipe names the behaviours; the tutorials above have shown how each is typed. -*Racing* (`racing.game`). A `scene3d` room, a `vehicle` behaviour on a +*Racing* (`racing.forge`). A `scene3d` room, a `vehicle` behaviour on a mesh -- `type` `car`, `motorcycle`, `tank`, or `boat`, with its `mass`, `thrust`, `torque`, and wheel sizes -- driven by `keys` through the vars `dy` (throttle) and `dx` (steering), a `camera` in `follow` mode behind it, and @@ -1016,50 +1018,50 @@ opponents on `racer`, which drives a vehicle round a track of points on its own. A `trigger` across the finish line, a `lap` var, and a `timer` for the clock. -*Tower defence* (`towerdefence.game`). Creeps on `patrol` along a list of +*Tower defence* (`towerdefence.forge`). Creeps on `patrol` along a list of points to the base, a `spawner` making waves of them, towers with `turret` -- `targets` the creep type, a `range`, a `rate`, and a `spawns` projectile or plain `damage` -- placed by `spawnAtPointer` on a click while the gold var allows, and `patrolEnd` costing a life. -*Pool and water* (`pool.game`). A `mesh` with the `water` behaviour is a +*Pool and water* (`pool.forge`). A `mesh` with the `water` behaviour is a pool: bodies inside it float or sink by their `buoyancy`, and a `soft` mesh is a cloth or a balloon. `body` gives anything mass, bounce, and friction; `push` shoves it; `joint` hangs one body from another by a hinge, a ball, or a slider. -*Bowling* (`bowling.game`). Ten `body` pins and a ball pushed along the +*Bowling* (`bowling.forge`). Ten `body` pins and a ball pushed along the lane, a `trigger` behind the pins that counts what falls through, and a `frame` rule that resets the rack with `moveTo` when the ball is past them. -*The maze* (`maze.game`). A `grid` look draws a map of tiles from a sheet +*The maze* (`maze.forge`). A `grid` look draws a map of tiles from a sheet -- `columns` wide, `tile` pixels square, the `map` rows of tile numbers -- and `solid` walls are entities placed on it; `keys` and `mover` with `clamp` walk the maze, a `timer` runs the clock, and `enter` on the exit `trigger` ends it. -*Third person* (`thirdperson.game`). Tutorial 8's fox with an `orbit` +*Third person* (`thirdperson.forge`). Tutorial 8's fox with an `orbit` camera: the mouse turns the view, the arrows walk relative to it, and `camera.eye` raises the viewpoint. `animator` with `attack` and `hit` clips, `health` on what it fights, and `animationDone` to return to `idle`. -*Space* (`space.game`). `thrust` pushes a body along its nose by `dy` and +*Space* (`space.forge`). `thrust` pushes a body along its nose by `dy` and turns it by `dx` -- a ship without lift -- with `projectile`s that `aim` along their flight and a `spawner` of rocks that `drift`. -*Rhythm and MIDI* (`rhythm.game`). A game that opened a MIDI port hears +*Rhythm and MIDI* (`rhythm.forge`). A game that opened a MIDI port hears `midi` events with `event.pitch` and `event.velocity`; a `timeBetween` window round each beat, and `once` per note, scores the hit. A `music` layer plays the track from the start. -*The 3D adventure* (`adventure3d.game`). Tutorial 7's rooms in a scene: +*The 3D adventure* (`adventure3d.forge`). Tutorial 7's rooms in a scene: `walker` on the floors named in the room's `navFrom`, hotspots with a `walkX`, `walkY`, `walkZ`, a painted backdrop on an unlit mesh, and `occluder` boxes where the painting's furniture stands, so the hero walks behind it. The verbs, the inventory, and the dialogues are exactly the flat ones. -*The board and the bezel* (`fmv.game`). `bezel` round any game, `credit` +*The board and the bezel* (`fmv.forge`). `bezel` round any game, `credit` on the coin switch, `restart` on start with credits, and `submitScore` at the end; `branching` for a Dragon's Lair of windows, moves, and jumps. @@ -1139,7 +1141,7 @@ Compile it and run what comes out: [source,lua] ---- dofile("Forge/AuthorCompile.singe") -dofile(authorBuild("mygame.game", singeGetDataPath() .. "mygame.singe")) +dofile(authorBuild("mygame.forge", singeGetDataPath() .. "mygame.singe")) ---- `testScripts/author/` holds the worked examples -- a platformer, a QTE over @@ -1273,7 +1275,7 @@ with `ragdoll` lets a model fall limp on death; `solid`, `body`, and pointer, so the same `hit` and `miss` rules serve a light gun over video and a rail shooter. -`testScripts/author/railshooter.game` is House of the Dead in a table: a +`testScripts/author/railshooter.forge` is House of the Dead in a table: a rail with two stops, zombies that spawn there, walk to the camera, and bite on a timer unless they are killed, headshots through a zone on the head bone, and the rail moving on when the stop is clear. @@ -1298,13 +1300,73 @@ picture is drawn over what is higher, and `scaleBy = { { y = 300, scale = 0.6 smaller; looks with `anchor = "feet"` stand on their position rather than being centred on it, which is what a walking character wants. +*Overlay bits.* A `text` look may have a `font` file and `size` of its own +and an `anchor` -- `centre`, `feet`, or `top` -- instead of its top left; a +`sprite` look draws the var `copies` copies of itself `step` pixels apart, a +row of bullets or of hearts; the `pointer` behaviour keeps an instance on +the player's pointer, a crosshair, and hides it when the engine says a real +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 ActionMax 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 <<own-vocabulary>>), not one of Forge's. + +*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 LINEA, 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 the same author's Time Gal remake, 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 Super Don Quixote, +the one hybrid of map-mode data under the Karis framework's judging, +with its difficulty select, level clear, and trophies. + +*A hand-written game.* A game that was a program of its own rather than +a framework's tables -- the American Laser Games titles, Mad Dog McCree +first among them and the typing edition of Mad Dog II the ninth, and +Platoon, a prototype in their shape -- is ported as a vocabulary of its own beside +its description (see +<<own-vocabulary>>): a behaviour that is the game's loop rewritten in +Forge's terms, its screens and each level's flow, over the game's own +data files (its level setups, its hitbox tables, its hit tests, its +clips) loaded through the same shim the framework ports use. A press +carries the device it came from, so two guns are told apart, and the +hitbox behaviour's `step` keeps a box only on the frames a track keys. + *3D over a painting.* A room in the scene may be a picture on a flat `mesh` (its `texture`, `unlit`) with 3D characters in front of it. Whatever stands in the picture -- a pillar, a doorway, a table -- that a character has to walk behind is a `mesh` look with `occluder = true`: an invisible box that hides what is behind it while the picture shows through it. The editor's 3D viewport keeps occluders visible so they can be placed; the game hides them. -`testScripts/author/painted.game` and scene 79 show a character crossing a +`testScripts/author/painted.forge` and scene 79 show a character crossing a painted room behind such a pillar. *Hotspots.* A type with `{ kind = "hotspot", name = "door", walkX = 600, @@ -1374,8 +1436,8 @@ slot, and `loadGame` brings it back. A save taken while a sequence runs records the world as it stands; the sequence does not resume. `die` is the Sierra death: a line, a fade, and the game over from the start. -`testScripts/author/adventure2d.game` is the two-room adventure in a painted -room, and `adventure3d.game` is the same game in a modelled room from a fixed +`testScripts/author/adventure2d.forge` is the two-room adventure in a painted +room, and `adventure3d.forge` is the same game in a modelled room from a fixed camera -- it takes the 2D description's rules, dialogue, verbs, and parser as they are and supplies only types and rooms. Scenes 64 and 65 play them. @@ -1405,8 +1467,8 @@ body along its nose by `dy` and turns it by `dx` (a hovercraft, a ship); or a slider through a point along an axis; and `push` shoves a body, or a ragdoll's bone. -`testScripts/author/racing.game` (a car, a rival on a track, checkpoint -triggers, a hinged bar, a hovercraft) and `towerdefence.game` (creeps in +`testScripts/author/racing.forge` (a car, a rival on a track, checkpoint +triggers, a hinged bar, a hovercraft) and `towerdefence.forge` (creeps in waves on a lane, turrets placed by clicks, gold and lives) are the worked examples; scenes 66 and 67 play them. @@ -1416,14 +1478,17 @@ A `branches` track is Dragon's Lair: each branch is a window of disc frames, the `move` (a key) or `switch` it wants, and the frames the disc goes to on `success` and on `fail`; a type with `{ kind = "branching", track = "moves" }` watches it and raises `branchOpen` (with `event.move`), `branchTaken`, and -`branchMissed`. Lives, credits, and continues are vars and rules -- `credit` +`branchMissed`. A branch with `mash = 6` wants the move six times before the +window closes, and one with `hold = 20` wants it held for twenty frames of +the window; `branchOpen` carries `event.mash` and `event.hold` so a prompt +can say so. Lives, credits, and continues are vars and rules -- `credit` on `SWITCH_COIN1`, a `restart` on `SWITCH_START1` when `credits > 0` -- and a `bezel` layer shows `score`, `lives`, and `credits` (and a second player's `score2` and `lives2` with `twin`) on the arcade panel around the picture. `gameOver` ends the rules, keeps the best score across runs, and shows the results card; `submitScore` sends the score to the master service's board -for the game, queued until it can go. `testScripts/author/fmv.game` is the +for the game, queued until it can go. `testScripts/author/fmv.forge` is the worked example and scene 68 plays it: one window answered, three missed, the lives spent, the score sent, a coin, and a continue. @@ -1464,7 +1529,7 @@ In the scene, a mesh look with `water` is filled with water: its top is the surface, and a `body` with `buoyancy` floats in it; a `vehicle` of type `boat` with `thrust` is pushed by its propeller while that sits under the surface; and `soft` makes a mesh look a cloth or an inflated body. -`testScripts/author/tail.game` and `pool.game` are the worked examples; +`testScripts/author/tail.forge` and `pool.forge` are the worked examples; scenes 70 and 71 play them. === The way out @@ -1496,6 +1561,73 @@ empty, is reported by the checker as *empty* and compiles to `nil`. include::ForgeVocabulary.adoc[] +[[own-vocabulary]] +=== Your own vocabulary + +A game can bring words of its own. A description may name a file of Lua -- + +[source,lua] +---- +vocabulary = "Vocabulary.singe", +---- + +-- kept beside the description, and everything that reads the description loads +that file before it reads the manifest: the editor when the game is opened +(and forgets it again when the game is closed, so the next game's panels do +not offer it), the compiler before it checks the description, and the built +game itself, which carries the file into a release like any other file the +description names and loads it from beside itself. Nothing in Forge changes; +the words exist while that game is open and nowhere else. + +The file adds entries to the `AUTHOR` table exactly as `Author.singe` does +for its own -- a look, a behaviour, a condition, an action, a layer, or an +event -- and may define any Lua the entries emit or call. A behaviour's +`attach`, `step`, and `on` are called as Forge calls them; a condition's or +action's `emit` returns the Lua a rule compiles to. A behaviour that reads a +key or switch resolves the name with `authorKeyValue` or `authorSwitchValue`, +and one that wants the presses about nothing in particular -- a trigger -- +sets `instance.hearsAll` and reads them in `on`. The five ActionMax games are +the working example: their `lightSensor` behaviour, which judges a shot the +way the emulator did by the disc's pixels under the gun, lives in a +`Vocabulary.singe` beside them and in no game that is not theirs. + +[source,lua] +---- +-- A bob: the entity rises and falls on a sine wave about where it was put. +AUTHOR.behaviours.bob = { + help = "Rises and falls about its place.", + params = { height = "number", speed = "number" }, + attach = function(instance, params) + local _, y = authorPosition(instance) + + instance.bob = { height = params.height or 20, speed = params.speed or 2, base = y, at = 0 } + end, + step = function(instance, dt) + local bob = instance.bob + local x, _, z = authorPosition(instance) + + bob.at = bob.at + dt * bob.speed + nodeSetPosition(instance.node, x, bob.base + math.sin(bob.at) * bob.height, z) + end +} + +-- Whether an instance bobs: a condition of this game's own. +AUTHOR.conditions.bobbing = { + help = "An instance has a bob.", + params = { entity = "entity" }, + emit = function(p) return "vocabBobbing(" .. p.entity .. ")" end +} + + +function vocabBobbing(instance) + return (instance ~= nil) and (instance.bob ~= nil) +end +---- + +The game's form has a `vocabulary` field, so the file can be named from the +editor; naming it loads it at once, and the type and rule pickers offer what +it added. + == The Editor `Forge/Forge.singe` edits a description, and it is itself a Singe game. It has @@ -1522,7 +1654,7 @@ A script can drive the editor instead, which is how the test scenes do it: ---- FORGE_LIBRARY = true dofile("Forge/Forge.singe") -forgeBegin("mygame.game") +forgeBegin("mygame.forge") function onOverlayUpdate() local x, y = mouseGetPosition(0) @@ -1579,8 +1711,9 @@ it. In a 3D room `A` puts the new entity on the floor under the middle of the viewport, and positions are world units to a tenth. `PgUp` and `PgDn` move between rooms; `N` adds a room. With nothing selected, `Enter` edits the game and then the room: the game's `title`, `players`, `vars` (as -`score=0, lives=3`), `verbs`, and `layers` (a list of names, as `disc, -overlay`) with each layer's own parameters as `disc.file` or `scene3d.fov` +`score=0, lives=3`), `verbs`, `layers` (a list of names, as `disc, +overlay`) with each layer's own parameters as `disc.file` or `scene3d.fov`, +and `vocabulary` (a file of the game's own words, <<own-vocabulary>>) -- a table parameter is typed as text: a map such as `scene3d.ambient` as `r=80, g=80, b=100`, a list of points such as `spawner.points` as `100,-20; 300,-20`, a list of records such as `target.zones` as @@ -1737,7 +1870,7 @@ own, `CoinRun/CoinRun.singe`, which is how both the menu and a packed copy address them), the description it was built from so it can be opened again, **a copy of the runtime**, taken out of Forge, and **every file the description names** -- looks, sounds, music, the disc, models, -a HUD document -- at the same relative name it had, so the kit's +a HUD document, the game's own vocabulary -- at the same relative name it had, so the kit's `Forge/kit/hero.png` lands in `Forge/kit/` under the release. The engine's own files (`Singe/...`) and absolute paths are left where they are. The compiled script notes its own directory in `AUTHOR_DIR`, and the runtime looks diff --git a/docs/ForgeVocabulary.adoc b/docs/ForgeVocabulary.adoc index ebdb831a9..a1f0043f8 100644 --- a/docs/ForgeVocabulary.adoc +++ b/docs/ForgeVocabulary.adoc @@ -79,12 +79,12 @@ | `b` (number), `g` (number), `gravity` (number), `life` (number), `r` (number), `rate` (number), `size` (number), `speed` (number), `spread` (number) | `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. -| `anchor` (feet), `faces` (right, left, none), `file` (file), `frames` (number) +| 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) | `text` -| A line of text, its top left at the instance. The var text is what it says. -| `b` (number), `g` (number), `r` (number), `text` (string) +| A line of text, its top left at the instance -- or centred on it, standing on it (feet), or hanging from it (top). The var text is what it says. A font file and size of its own, or the game's. +| `anchor` (centre, feet, top), `b` (number), `font` (file), `g` (number), `r` (number), `size` (number), `text` (string) | `text3d` | A line of text standing in the scene. The var text is what it says. @@ -107,7 +107,7 @@ | `bounce` (number), `buoyancy` (number), `friction` (number), `mass` (number), `shape` (box, sphere) | `branching` -| 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. Raises branchTaken and branchMissed. +| 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. | `track` (track) | `camera` @@ -135,7 +135,7 @@ | `keep` (boolean), `linger` (number), `max` (number), `ragdoll` (boolean) | `hitbox` -| The instance's hit shape comes from a track of boxes keyed by disc frame or time; between keys it is interpolated, outside them there is none. +| The instance's hit shape comes from a track of boxes keyed by disc frame or time; between keys it is interpolated, outside them there is none. A track with step = true keeps a box only on the frames it keys. | `track` (track) | `hotspot` @@ -150,6 +150,10 @@ | 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. +| -- + | `mover` | Moves by the vars dx and dy at a speed, kept on the screen when clamp is set. Pair it with keys. | `clamp` (boolean), `speed` (number) @@ -162,14 +166,30 @@ | Runs, falls, and jumps: the engine's character controller in 2D. Rules say which way with run and jump. Its point is its feet, so its look stands on it. Sets the var facing, and the var state to idle, walk, jump, or fall unless a rule set another. | `jump` (number), `speed` (number) +| `pointer` +| Follows a player's pointer: a crosshair, a hand. Hidden when the engine says a real gun wants no crosshair drawn. +| `player` (number) + | `projectile` | Flies at a velocity and is dropped past the edge (or far away, in 3D) or after life seconds. With aim, a sprite turns to point the way it flies. | `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. +| -- + | `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 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. +| -- + | `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) @@ -187,8 +207,8 @@ | `moving` (boolean) | `sound` -| A clip per event: spawn, hit, death, or any event the instance sees. Values are file names. volume is the clips' own, 0 to 100, under the game's. -| `death` (file), `hit` (file), `pressed` (file), `spawn` (file), `volume` (number) +| A clip per event: spawn, hit, miss, death, fire (a gun's trigger), or any event the instance sees. Values are file names. volume is the clips' own, 0 to 100, under the game's. +| `death` (file), `fire` (file), `hit` (file), `miss` (file), `pressed` (file), `spawn` (file), `volume` (number) | `spawner` | Makes instances of a type every so many seconds at its own position or at one of its points, up to max alive at once and total in all. @@ -206,6 +226,10 @@ | 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 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. +| -- + | `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) @@ -251,7 +275,7 @@ | -- | `branchOpen` -| A branch's window opened; event.move says what it wants. +| A branch's window opened; event.move says what it wants, event.mash how many times, event.hold for how many frames. | -- | `branchTaken` @@ -477,6 +501,16 @@ | `text` (expression) | yes +| `discPause` +| Hold the disc on its frame: a still behind a title. +| -- +| + +| `discPlay` +| Play the disc from where it stands. +| -- +| + | `discTo` | Send the disc to a frame -- the branch a QTE takes. | `frame` (number) diff --git a/docs/Manual.adoc b/docs/Manual.adoc index 58bcf0e47..a317260b0 100644 --- a/docs/Manual.adoc +++ b/docs/Manual.adoc @@ -563,7 +563,7 @@ name and any extension FFmpeg can demux, then for a `.txt` framefile. | `--altaudio=SUFFIX` | Play `<base><SUFFIX>.ogg` beside the disc's video instead of the audio inside it, for a release whose other languages ship as separate files: `--altaudio=-es` next to `lair.m2v` plays `lair-es.ogg`. Every segment of a framefile is switched together. A file that is not there leaves the game's own audio playing and prints a warning. The `AUDIO_SUFFIX` key in `games.dat` does the same for one game, and a script changes it while running with `discAudioSuffix`. Default: none. | `--apiversion` | Print one machine readable line describing this build to standard output and exit, for front ends. See <<apiversion,The Version Line>>. Nothing else is printed. | `--deinterlace=MODE` | What an interlaced picture gets: `auto` (the default) deinterlaces only the frames the file marks interlaced, `on` deinterlaces every frame for a file whose flags are wrong, and `off` never touches the picture. A laserdisc held interlaced fields and a rip that kept them combs on a progressive display; a rip that was deinterlaced when it was made needs nothing here. One picture comes out for each one that goes in, so frame numbers never move. Default: `auto`. -| `--deterministic[=MS]` | For testing only: ignore real time and run the whole engine on a virtual clock that moves `MS` milliseconds every frame, so the same frame number always means the same moment. The disc steps one video frame a frame with it, and the random generators are seeded from the same number, so a run repeats to the pixel. The value is optional and is both the step and the seed, `1` to `1000`. Default when given without one: `15`, the frame time the engine's own rate implies. Audio and pacing are meaningless in this mode; see <<deterministic,Deterministic Test Mode>>. Default: off. +| `--deterministic[=MS]` | For testing only: ignore real time and run the whole engine on a virtual clock that moves `MS` milliseconds every frame, so the same frame number always means the same moment. The disc follows the virtual clock too, its frames lasting the time they are worth, and the random generators are seeded from the same number, so a run repeats to the pixel. The value is optional and is both the step and the seed, `1` to `1000`. Default when given without one: `15`, the frame time the engine's own rate implies. Audio and pacing are meaningless in this mode; see <<deterministic,Deterministic Test Mode>>. Default: off. | `--fvalue=NUMBER` | One number handed from the launcher to the game, which reads it with `getFValue()`. Singe does nothing with it. `0` to `100000`, kept to three decimals as Hypseus keeps it. Default: `0`. | `--gamepad_reorder=DIGITS` | Which physical pad fills which gamepad slot, as enumeration positions counting from `0`, one for each slot in turn: `--gamepad_reorder=10` makes the second pad found player one and the first player two. Written as bare digits (`3210`) or separated by commas or spaces, as Hypseus writes it; a repeated position is refused. Positions not named fill the slots that are left, in the order SDL found them, and a position with no pad behind it leaves its slot empty. Default: SDL's own order. | `--haptic=STEP` | The strongest rumble step a game may use, `0` to `4`. `0` turns rumble off altogether, and a lower number quietens a game that asks for more: `controllerDoRumble` never rumbles harder than this. Default: `4`, which is every step a game asks for. @@ -930,10 +930,14 @@ ignore real time altogether: instead of asking the machine what time it is, the engine runs on a virtual clock that moves a fixed step -- 15 milliseconds by default -- exactly once a frame. Everything paced by time follows that clock: physics steps, sprite and model animation, particles, navigation, the GUI's -animations and transitions, `os.clock()` and `singeGetTicks()`. The disc follows -it too: while it is playing it advances exactly one video frame for every frame -the engine draws, from wherever it was last parked, and a paused or searched -disc stays where it was put. Lua's generator and the engine's own are seeded +animations and transitions, `os.clock()`, `singeGetTicks()`, and a bare +`os.time()` (a game that seeds its generator from the wall clock when a game +starts seeds it from the virtual one, so it draws the same numbers every run; +`os.time` given a date table converts it as Lua always has). The disc follows +it too: while it is playing, each video frame stays on for as many engine frames +as its presentation time is worth on that clock (two or three at the default +step for a thirty-frame disc), as in real play, and a paused or searched disc +stays where it was put. Lua's generator and the engine's own are seeded from a fixed number. Nothing waits for anything, so the game runs as fast as the machine will let it. @@ -1230,21 +1234,25 @@ function onControllerMoved(axis, value, which) --]] end -function onInputPressed(what) +function onInputPressed(what, device) --[[ When in keyboard MODE_NORMAL, input events are reported here when the key or button is first depressed. For a full list of keys, buttons, and controllers, see Singe/Framework.singe. For MODE_FULL, this event - will be called with the keysym of the key pressed. + will be called with the keysym of the key pressed. When the switch + came from a mouse or a light gun, device is that device's index + (0 for the first), as Hypseus passes it, so a two-gun game can tell + its players apart; for a key or a pad it is nil. --]] end -function onInputReleased(what) +function onInputReleased(what, device) --[[ When in keyboard MODE_NORMAL, input events are reported here when the key or button is released. For a full list of keys, buttons, and controllers, see Singe/Framework.singe. For MODE_FULL, this event - will be called with the keysym of the key released. + will be called with the keysym of the key released. device is as + for onInputPressed. --]] end @@ -2220,7 +2228,8 @@ more than a Raspberry Pi 4 shares with its display. Textures shipped as KTX2 files (Basis Universal, made by `util/compressTextures.py` from PNGs or from a whole `.gltf`) stay compressed on the GPU at a quarter of the size or less, in whichever block format the machine has: BC7 on desktops, ASTC on -Apple GPUs and the Pi 4, BC3 on older cards, and plain RGBA where nothing +Apple GPUs and the Pi 4, ETC2 on any OpenGL ES 3 part (every handheld's +Mali, the Pi under GLES), BC3 on older cards, and plain RGBA where nothing else exists, all from the one file. Give a `.ktx2` name in place of a sprite to `materialSetTexture` and the map calls, and a model whose file uses `KHR_texture_basisu` loads them on its own. `sceneGetStats` reports the @@ -7027,7 +7036,7 @@ fontQuality(FONT_QUALITY_BLENDED) fontSelect(id) ---- -Makes a loaded font the one used by `fontPrint` and `fontToSprite`. The selection persists until the next `fontSelect` or `fontLoad`, since loading also selects. It can be changed as often as needed, including several times within one `onOverlayUpdate`. An unknown or unloaded handle ends the script with an error. +Makes a loaded font the one used by `fontPrint` and `fontToSprite`. The selection persists until the next `fontSelect` or `fontLoad`, since loading also selects. It can be changed as often as needed, including several times within one `onOverlayUpdate`. An unknown or unloaded handle (or no number at all) leaves the current font selected and prints a warning, as Hypseus Singe lets it pass. *Since:* 1.x *See also:* <<fontload,fontLoad>>, <<fontprint,fontPrint>>, <<fonttosprite,fontToSprite>> @@ -10052,10 +10061,10 @@ musicPause() [source,text] ---- -musicPlay(id [, loops]) +id = musicPlay(id [, loops]) ---- -Hypseus extension. Plays a track from its beginning. `loops` means what <<soundplay,`soundPlay`>>'s does: `0`, the default, plays once, `-1` plays forever, and `N` repeats `N` more times. The count is clamped to `-1` to `64`, as Hypseus clamps it, so a game asking for more gets what it would have got there. +Hypseus extension. Plays a track from its beginning and answers its handle, which is how Hypseus's games keep it (`sndHandle = musicPlay(music)`) to stop the piece later. `loops` means what <<soundplay,`soundPlay`>>'s does: `0`, the default, plays once, `-1` plays forever, and `N` repeats `N` more times. The count is clamped to `-1` to `64`, as Hypseus clamps it, so a game asking for more gets what it would have got there. Playing a track that is already playing starts it again from the beginning. @@ -10064,7 +10073,7 @@ Playing a track that is already playing starts it again from the beginning. * `id` -- number; a music handle. * `loops` -- optional number; `-1` to `64`. Default `0`. -*Returns:* nothing. +*Returns:* number; the handle it was given. *Since:* 3.00, as a Hypseus Singe extension. *See also:* <<musicstop,musicStop>>, <<musicsetvolume,musicSetVolume>> @@ -10138,7 +10147,7 @@ musicSetVolume(soundGetVolume() * 2) musicStop([id [, fadeMs]]) ---- -Hypseus extension. Stops that track, or every piece of music when no handle is given. `fadeMs` fades it out over that many milliseconds instead of cutting it; `0`, the default, stops at once. A negative fade terminates the script. +Hypseus extension. Stops that track, or every piece of music when no handle is given (a `nil` handle counts as none). `fadeMs` fades it out over that many milliseconds instead of cutting it; `0`, the default, stops at once. A negative fade terminates the script. *Parameters:* diff --git a/src/ktx2.h b/src/ktx2.h index 8ca899852..70df977e1 100644 --- a/src/ktx2.h +++ b/src/ktx2.h @@ -35,7 +35,8 @@ typedef enum Ktx2FormatE { KTX2_RGBA = 0, // Uncompressed, the fallback KTX2_BC7 = 1, // Desktop GPUs KTX2_ASTC = 2, // ASTC 4x4: Apple GPUs, the Raspberry Pi 4 and phones - KTX2_BC3 = 3 // Older desktop GPUs + KTX2_BC3 = 3, // Older desktop GPUs + KTX2_ETC2 = 4 // ETC2 with EAC alpha: the one block format OpenGL ES 3 guarantees (handhelds, the Pi under GLES) } Ktx2FormatE; typedef struct Ktx2LevelS { diff --git a/src/ktx2Basis.cpp b/src/ktx2Basis.cpp index 7ee73e666..ff67f2d11 100644 --- a/src/ktx2Basis.cpp +++ b/src/ktx2Basis.cpp @@ -48,6 +48,8 @@ static basist::transcoder_texture_format _target(Ktx2FormatE format) { return basist::transcoder_texture_format::cTFASTC_4x4_RGBA; case KTX2_BC3: return basist::transcoder_texture_format::cTFBC3_RGBA; + case KTX2_ETC2: + return basist::transcoder_texture_format::cTFETC2_RGBA; default: return basist::transcoder_texture_format::cTFRGBA32; } diff --git a/src/render.c b/src/render.c index d614aaf60..ff3d0d116 100644 --- a/src/render.c +++ b/src/render.c @@ -332,6 +332,11 @@ bool rgpuSubmitCommandBuffer(SDL_GPUCommandBuffer *commandBuffer) { bool rgpuTextureSupportsFormat(SDL_GPUDevice *device, SDL_GPUTextureFormat format, SDL_GPUTextureType type, SDL_GPUTextureUsageFlags usage) { + // A format of our own naming (ETC2) is the GLES backend's alone; SDL's function would index + // its tables past the end with it. + if ((format >= RGPU_TEXTUREFORMAT_PRIVATE_FIRST) && (_api != RENDER_GLES)) { + return false; + } return _backend->textureSupportsFormat(device, format, type, usage); } diff --git a/src/render.h b/src/render.h index ce5748600..fb49c33fb 100644 --- a/src/render.h +++ b/src/render.h @@ -53,6 +53,13 @@ extern "C" { // code pointer is then the ES source string rather than a blob, and its size is ignored. #define RGPU_SHADERFORMAT_ESSL (1u << 16) +// Texture formats SDL_GPU has no name for, kept past the end of its enum: ETC2 with EAC alpha, +// the one block format OpenGL ES 3 guarantees. The GLES backend offers them; the SDL_GPU backend +// never sees them (render.c answers no before asking it). +#define RGPU_TEXTUREFORMAT_PRIVATE_FIRST ((SDL_GPUTextureFormat)(SDL_GPU_TEXTUREFORMAT_ASTC_12x12_FLOAT + 1)) +#define RGPU_TEXTUREFORMAT_ETC2_RGBA8_UNORM ((SDL_GPUTextureFormat)(SDL_GPU_TEXTUREFORMAT_ASTC_12x12_FLOAT + 1)) +#define RGPU_TEXTUREFORMAT_ETC2_RGBA8_UNORM_SRGB ((SDL_GPUTextureFormat)(SDL_GPU_TEXTUREFORMAT_ASTC_12x12_FLOAT + 2)) + typedef struct RenderBackendS { SDL_GPUCommandBuffer * (*acquireCommandBuffer)(SDL_GPUDevice *device); diff --git a/src/renderGles.c b/src/renderGles.c index 3a33e2791..9a776947b 100644 --- a/src/renderGles.c +++ b/src/renderGles.c @@ -58,6 +58,8 @@ #define GLES_FRAMEBUFFER_CACHE 32 #define GLES_LOG_BYTES 1024 // A driver's compile or link log, which is the only clue it gives #define GLES_NAME_BYTES 128 // A uniform block's name as SPIRV-Cross emits it +#define GLES_BLOCK_SIDE 4 // Pixels along a block's side, ETC2 and ASTC 4x4 alike +#define GLES_BLOCK_BYTES 16 // Bytes a block takes in either // A texture's storage. A multisampled colour target is a renderbuffer rather than a texture: the // scene only ever resolves it and never samples it, and multisampled textures are ES 3.1 with an @@ -153,6 +155,7 @@ typedef struct GlesDeviceS { uint32_t uniformOffset; int32_t uniformAlignment; GLfloat maxAnisotropy; // 0 when the driver has no GL_EXT_texture_filter_anisotropic + bool astc; // GL_KHR_texture_compression_astc_ldr: ASTC 4x4 textures bool uniformWrapped; bool ready; } GlesDeviceT; @@ -165,6 +168,7 @@ static void _bindTextureUnit(uint32_t unit, GlesTextureT *te static GLenum _blendFactor(SDL_GPUBlendFactor factor); static GLenum _blendOp(SDL_GPUBlendOp op); static GLenum _compare(SDL_GPUCompareOp op); +static uint32_t _compressedBytes(SDL_GPUTextureFormat format, uint32_t width, uint32_t height); static void _formatToGl(SDL_GPUTextureFormat format, GLenum *internalFormat, GLenum *layout, GLenum *type); static GLuint _framebufferFor(const SDL_GPUColorTargetInfo *colour, uint32_t colourCount, const SDL_GPUDepthStencilTargetInfo *depth, uint32_t *width, uint32_t *height); static SDL_GPUCommandBuffer * _glesAcquireCommandBuffer(SDL_GPUDevice *device); @@ -332,13 +336,42 @@ static GLenum _compare(SDL_GPUCompareOp op) { } +// The bytes a block-compressed level of the given size takes, or zero for a format that is not +// block compressed. ETC2 with EAC alpha and ASTC 4x4 both pack a 4x4 block into sixteen bytes. +static uint32_t _compressedBytes(SDL_GPUTextureFormat format, uint32_t width, uint32_t height) { + bool block = (format == RGPU_TEXTUREFORMAT_ETC2_RGBA8_UNORM) || (format == RGPU_TEXTUREFORMAT_ETC2_RGBA8_UNORM_SRGB) || (format == SDL_GPU_TEXTUREFORMAT_ASTC_4x4_UNORM) || (format == SDL_GPU_TEXTUREFORMAT_ASTC_4x4_UNORM_SRGB); + + if (!block) { + return 0; + } + + return ((width + GLES_BLOCK_SIDE - 1) / GLES_BLOCK_SIDE) * ((height + GLES_BLOCK_SIDE - 1) / GLES_BLOCK_SIDE) * GLES_BLOCK_BYTES; +} + + // The formats the engine actually asks for. Anything else reports zero, and rgpuTextureSupportsFormat // turns that into a plain no so the caller falls back the way it already does on a weak device. +// The block formats: ETC2 is every ES 3 part's, ASTC 4x4 the extension's. static void _formatToGl(SDL_GPUTextureFormat format, GLenum *internalFormat, GLenum *layout, GLenum *type) { *internalFormat = 0; *layout = GL_RGBA; *type = GL_UNSIGNED_BYTE; + // (The ETC2 names are ours, past SDL's enum, so they are not cases of its switch.) + if (format == RGPU_TEXTUREFORMAT_ETC2_RGBA8_UNORM) { + *internalFormat = GL_COMPRESSED_RGBA8_ETC2_EAC; + return; + } + if (format == RGPU_TEXTUREFORMAT_ETC2_RGBA8_UNORM_SRGB) { + *internalFormat = GL_COMPRESSED_SRGB8_ALPHA8_ETC2_EAC; + return; + } switch (format) { + case SDL_GPU_TEXTUREFORMAT_ASTC_4x4_UNORM: + *internalFormat = _device.astc ? GL_COMPRESSED_RGBA_ASTC_4x4_KHR : 0; + break; + case SDL_GPU_TEXTUREFORMAT_ASTC_4x4_UNORM_SRGB: + *internalFormat = _device.astc ? GL_COMPRESSED_SRGB8_ALPHA8_ASTC_4x4_KHR : 0; + break; case SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM: *internalFormat = GL_RGBA8; break; @@ -1307,13 +1340,20 @@ static void _glesUploadToTexture(SDL_GPUCopyPass *copyPass, const SDL_GPUTexture GLenum internalFormat; GLenum layout; GLenum type; + uint32_t compressed; (void)copyPass; (void)cycle; _formatToGl(texture->format, &internalFormat, &layout, &type); glBindTexture(texture->target, texture->name); glPixelStorei(GL_UNPACK_ALIGNMENT, 1); - if (texture->target == GL_TEXTURE_2D) { + compressed = _compressedBytes(texture->format, destination->w, destination->h); + if (compressed > 0) { + // A block-compressed level arrives whole from the transcoder; its size is its block count. + GLenum face = (texture->target == GL_TEXTURE_CUBE_MAP) ? (GL_TEXTURE_CUBE_MAP_POSITIVE_X + destination->layer) : GL_TEXTURE_2D; + + glCompressedTexSubImage2D(face, (GLint)destination->mip_level, (GLint)destination->x, (GLint)destination->y, (GLsizei)destination->w, (GLsizei)destination->h, internalFormat, (GLsizei)compressed, transfer->data + source->offset); + } else if (texture->target == GL_TEXTURE_2D) { glTexSubImage2D(GL_TEXTURE_2D, (GLint)destination->mip_level, (GLint)destination->x, (GLint)destination->y, (GLsizei)destination->w, (GLsizei)destination->h, layout, type, transfer->data + source->offset); } else if (texture->target == GL_TEXTURE_CUBE_MAP) { glTexSubImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + destination->layer, (GLint)destination->mip_level, (GLint)destination->x, (GLint)destination->y, (GLsizei)destination->w, (GLsizei)destination->h, layout, type, transfer->data + source->offset); @@ -1565,6 +1605,8 @@ bool renderGlesStart(void) { glGetFloatv(GL_MAX_TEXTURE_MAX_ANISOTROPY_EXT, &_device.maxAnisotropy); } utilTrace("Gles: anisotropy %s", _device.maxAnisotropy > 1.0f ? "available" : "not offered"); + _device.astc = (extensions != NULL) && (strstr(extensions, "GL_KHR_texture_compression_astc_ldr") != NULL); + utilTrace("Gles: block textures: ETC2, %s", _device.astc ? "and ASTC 4x4" : "no ASTC"); } glGetIntegerv(GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT, &_device.uniformAlignment); if (_device.uniformAlignment <= 0) { diff --git a/src/renderGlesApi.h b/src/renderGlesApi.h index 6d2a189ae..379fdd7d5 100644 --- a/src/renderGlesApi.h +++ b/src/renderGlesApi.h @@ -73,6 +73,10 @@ typedef intptr_t GLsizeiptr; #define GL_COLOR_BUFFER_BIT 0x00004000 #define GL_COMPARE_REF_TO_TEXTURE 0x884E #define GL_COMPILE_STATUS 0x8B81 +#define GL_COMPRESSED_RGBA8_ETC2_EAC 0x9278 +#define GL_COMPRESSED_RGBA_ASTC_4x4_KHR 0x93B0 +#define GL_COMPRESSED_SRGB8_ALPHA8_ASTC_4x4_KHR 0x93D0 +#define GL_COMPRESSED_SRGB8_ALPHA8_ETC2_EAC 0x9279 #define GL_CONSTANT_COLOR 0x8001 #define GL_CULL_FACE 0x0B44 #define GL_CW 0x0900 @@ -220,6 +224,7 @@ typedef void (GL_APIENTRYP GlesClearDepthfFn)(GLfloat d); typedef void (GL_APIENTRYP GlesClearStencilFn)(GLint s); typedef void (GL_APIENTRYP GlesColorMaskFn)(GLboolean red, GLboolean green, GLboolean blue, GLboolean alpha); typedef void (GL_APIENTRYP GlesCompileShaderFn)(GLuint shader); +typedef void (GL_APIENTRYP GlesCompressedTexSubImage2DFn)(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLsizei width, GLsizei height, GLenum format, GLsizei imageSize, const void *data); typedef void (GL_APIENTRYP GlesCopyTexSubImage2DFn)(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLint x, GLint y, GLsizei width, GLsizei height); typedef GLuint (GL_APIENTRYP GlesCreateProgramFn)(void); typedef GLuint (GL_APIENTRYP GlesCreateShaderFn)(GLenum type); @@ -313,6 +318,7 @@ typedef struct GlesApiS { GlesClearStencilFn clearStencil; GlesColorMaskFn colorMask; GlesCompileShaderFn compileShader; + GlesCompressedTexSubImage2DFn compressedTexSubImage2D; GlesCopyTexSubImage2DFn copyTexSubImage2D; GlesCreateProgramFn createProgram; GlesCreateShaderFn createShader; @@ -413,6 +419,7 @@ bool renderGlesLoad(void); #define glClearStencil _glesApi.clearStencil #define glColorMask _glesApi.colorMask #define glCompileShader _glesApi.compileShader +#define glCompressedTexSubImage2D _glesApi.compressedTexSubImage2D #define glCopyTexSubImage2D _glesApi.copyTexSubImage2D #define glCreateProgram _glesApi.createProgram #define glCreateShader _glesApi.createShader diff --git a/src/renderGlesLoad.c b/src/renderGlesLoad.c index e07528498..087ffbb4a 100644 --- a/src/renderGlesLoad.c +++ b/src/renderGlesLoad.c @@ -54,6 +54,7 @@ bool renderGlesLoad(void) { _glesApi.clearStencil = (GlesClearStencilFn)SDL_GL_GetProcAddress("glClearStencil"); _glesApi.colorMask = (GlesColorMaskFn)SDL_GL_GetProcAddress("glColorMask"); _glesApi.compileShader = (GlesCompileShaderFn)SDL_GL_GetProcAddress("glCompileShader"); + _glesApi.compressedTexSubImage2D = (GlesCompressedTexSubImage2DFn)SDL_GL_GetProcAddress("glCompressedTexSubImage2D"); _glesApi.copyTexSubImage2D = (GlesCopyTexSubImage2DFn)SDL_GL_GetProcAddress("glCopyTexSubImage2D"); _glesApi.createProgram = (GlesCreateProgramFn)SDL_GL_GetProcAddress("glCreateProgram"); _glesApi.createShader = (GlesCreateShaderFn)SDL_GL_GetProcAddress("glCreateShader"); @@ -148,6 +149,7 @@ bool renderGlesLoad(void) { "glClearStencil", "glColorMask", "glCompileShader", + "glCompressedTexSubImage2D", "glCopyTexSubImage2D", "glCreateProgram", "glCreateShader", diff --git a/src/scene.c b/src/scene.c index 0c969db51..846ca5112 100644 --- a/src/scene.c +++ b/src/scene.c @@ -3560,6 +3560,9 @@ static SDL_GPUTexture *_uploadCompressed(const Ktx2ImageT *image, bool srgb) { case KTX2_BC3: info.format = srgb ? SDL_GPU_TEXTUREFORMAT_BC3_RGBA_UNORM_SRGB : SDL_GPU_TEXTUREFORMAT_BC3_RGBA_UNORM; break; + case KTX2_ETC2: + info.format = srgb ? RGPU_TEXTUREFORMAT_ETC2_RGBA8_UNORM_SRGB : RGPU_TEXTUREFORMAT_ETC2_RGBA8_UNORM; + break; default: info.format = srgb ? SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM_SRGB : SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM; break; @@ -5441,15 +5444,20 @@ bool sceneInit(SDL_GPUDevice *device, SDL_Renderer *renderer) { _scene.depthFormat = _depthFormat(); _scene.shadowFormat = _shadowFormat(); _scene.hdrFormat = _hdrFormat(); - // What KTX2 textures become: the best block format the device has, else plain RGBA. + // What KTX2 textures become: the best block format the device has, else plain RGBA. ETC2 + // comes before BC3 because every OpenGL ES 3 part has it, and a Mali without ASTC would + // otherwise fall all the way to RGBA on the devices with the least memory. _scene.compressedFormat = KTX2_RGBA; if (rgpuTextureSupportsFormat(device, SDL_GPU_TEXTUREFORMAT_BC7_RGBA_UNORM_SRGB, SDL_GPU_TEXTURETYPE_2D, SDL_GPU_TEXTUREUSAGE_SAMPLER)) { _scene.compressedFormat = KTX2_BC7; } else if (rgpuTextureSupportsFormat(device, SDL_GPU_TEXTUREFORMAT_ASTC_4x4_UNORM_SRGB, SDL_GPU_TEXTURETYPE_2D, SDL_GPU_TEXTUREUSAGE_SAMPLER)) { _scene.compressedFormat = KTX2_ASTC; + } else if (rgpuTextureSupportsFormat(device, RGPU_TEXTUREFORMAT_ETC2_RGBA8_UNORM_SRGB, SDL_GPU_TEXTURETYPE_2D, SDL_GPU_TEXTUREUSAGE_SAMPLER)) { + _scene.compressedFormat = KTX2_ETC2; } else if (rgpuTextureSupportsFormat(device, SDL_GPU_TEXTUREFORMAT_BC3_RGBA_UNORM_SRGB, SDL_GPU_TEXTURETYPE_2D, SDL_GPU_TEXTUREUSAGE_SAMPLER)) { _scene.compressedFormat = KTX2_BC3; } + utilTrace("Scene: KTX2 textures as %s", (const char *[]){ "RGBA", "BC7", "ASTC 4x4", "BC3", "ETC2" }[_scene.compressedFormat]); if (!_createShaders()) { sceneQuit(); return false; diff --git a/src/singe.c b/src/singe.c index 7c899447f..40be389f3 100644 --- a/src/singe.c +++ b/src/singe.c @@ -1271,6 +1271,7 @@ static int32_t apiNodeSetSpriteFrame(lua_State *L); static int32_t apiNodeSetText(lua_State *L); static int32_t apiNodeSetVisible(lua_State *L); static int32_t apiOsClock(lua_State *L); +static int32_t apiOsTime(lua_State *L); static int32_t apiOverlayBox(lua_State *L); static int32_t apiOverlayCircle(lua_State *L); static int32_t apiOverlayClear(lua_State *L); @@ -2631,7 +2632,13 @@ static void _deliverKey(bool down, int32_t keysym, int32_t scancode) { _global.switchHeld[move] = down; // A pad or mouse switch steers a GUI that takes input; a key already reached it as a key. if ((scancode < CODE_GAMEPAD_BASE) || !_guiSwitch((InputE)move, down)) { - _callLua(down ? "onInputPressed" : "onInputReleased", "i", move); + // A switch from a mouse or gun names its device too, as Hypseus does, so a + // two-gun game can tell its players apart. + if (scancode >= CODE_MOUSE_BASE) { + _callLua(down ? "onInputPressed" : "onInputReleased", "ii", move, (scancode - CODE_MOUSE_BASE) / CODE_MOUSE_STRIDE); + } else { + _callLua(down ? "onInputPressed" : "onInputReleased", "i", move); + } } break; } @@ -7503,7 +7510,12 @@ static SDL_Surface *_spriteTransform(SpriteT *sprite, SDL_Surface *source) { if ((sprite->angle == 0.0) && (sprite->scaleX == 1.0) && (sprite->scaleY == 1.0)) { copy = SDL_DuplicateSurface(source); } else { - copy = rotoZoomSurface(source, -sprite->angle, sprite->scaleX, sprite->scaleY, sprite->smooth != 0); + // A scale that would leave no pixels (a bullet shrinking to nothing) draws as one pixel + // rather than failing the transform; the scale the script set is kept as it reads back. + double scaleX = fmax(fabs(sprite->scaleX), 1.0 / (double)source->w); + double scaleY = fmax(fabs(sprite->scaleY), 1.0 / (double)source->h); + + copy = rotoZoomSurface(source, -sprite->angle, scaleX, scaleY, sprite->smooth != 0); } if ((copy != NULL) && (sprite->flip != SDL_FLIP_NONE)) { SDL_FlipSurface(copy, sprite->flip); @@ -7577,6 +7589,13 @@ static void _startLuaContext(lua_State *L) { lua_getglobal(L, "os"); lua_pushcfunction(L, apiOsClock); lua_setfield(L, -2, "clock"); + // A deterministic run reads the same wall clock every time it plays: games seed their + // generator from os.time() when a game starts, and the run must repeat. + if (_global.conf->deterministic) { + lua_getfield(L, -1, "time"); + lua_pushcclosure(L, apiOsTime, 1); + lua_setfield(L, -2, "time"); + } lua_pop(L, 1); // Games written for 2.x seed the generator with fractional numbers; Lua 5.4 refuses them. @@ -9856,10 +9875,22 @@ static int32_t apiFontQuality(lua_State *L) { } -// fontSelect(id) +// fontSelect(id) A font that was never loaded (a script naming one it misspelled) leaves the +// current font as it is with a warning, as Hypseus lets it pass, instead of ending the script. static int32_t apiFontSelect(lua_State *L) { + FontT *font = NULL; + int32_t id = 0; + _argCheck(L, "fontSelect", 1, 1); - _global.fontCurrent = _argFont(L, "fontSelect", 1); + if (lua_isnumber(L, 1)) { + id = (int32_t)lua_tointeger(L, 1); + HASH_FIND_INT(_global.fontList, &id, font); + } + if (!font) { + utilSay("Warning: fontSelect names no font (%s); the current font stays.", lua_isnumber(L, 1) ? "a bad index" : "not a number"); + return 0; + } + _global.fontCurrent = font; _luaTrace(L, "fontSelect", "%d", _global.fontCurrent->id); return 0; @@ -11306,9 +11337,10 @@ static int32_t apiMusicPause(lua_State *L) { } -// musicPlay(id [, loops]) Hypseus extension. loops means what soundPlay means: 0 (the default) -// plays once, -1 forever, N repeats N more times. Hypseus clamps the count to -1 to 64 and so does -// this, so a game asking for more gets what it would have got there. +// id = musicPlay(id [, loops]) Hypseus extension. loops means what soundPlay means: 0 (the +// default) plays once, -1 forever, N repeats N more times. Hypseus clamps the count to -1 to 64 and +// so does this, so a game asking for more gets what it would have got there. Answers the handle it +// was given, as Hypseus does: the games keep it (sndHandle = musicPlay(music)) to stop the piece. static int32_t apiMusicPlay(lua_State *L) { MusicT *music = NULL; int32_t loops = 0; @@ -11329,8 +11361,9 @@ static int32_t apiMusicPlay(lua_State *L) { } SDL_DestroyProperties(options); _luaTrace(L, "musicPlay", "%d loops %d", music->id, loops); + lua_pushinteger(L, music->id); - return 0; + return 1; } @@ -11375,12 +11408,13 @@ static int32_t apiMusicSetVolume(lua_State *L) { } -// musicStop([id [, fadeMs]]) Hypseus extension. With no handle every piece of music stops, as -// Hypseus stops whatever its music mixer is playing. A fade is in milliseconds. +// musicStop([id [, fadeMs]]) Hypseus extension. With no handle (or a nil one) every piece of +// music stops, as Hypseus stops whatever its music mixer is playing. A fade is in milliseconds. static int32_t apiMusicStop(lua_State *L) { MusicT *music = NULL; MusicT *next = NULL; int32_t fade = 0; + bool one = false; _argCheck(L, "musicStop", 0, 2); if (lua_gettop(L) >= 2) { @@ -11389,7 +11423,8 @@ static int32_t apiMusicStop(lua_State *L) { _luaDie(L, "musicStop", "A fade cannot be negative: %d", fade); } } - if (lua_gettop(L) >= 1) { + one = !lua_isnoneornil(L, 1); + if (one) { music = _argMusic(L, "musicStop", 1); MIX_StopTrack(music->track, MIX_TrackMSToFrames(music->track, fade)); } else { @@ -11397,7 +11432,7 @@ static int32_t apiMusicStop(lua_State *L) { MIX_StopTrack(music->track, MIX_TrackMSToFrames(music->track, fade)); } } - _luaTrace(L, "musicStop", "%d fade %d", (lua_gettop(L) >= 1) ? music->id : -1, fade); + _luaTrace(L, "musicStop", "%d fade %d", one ? music->id : -1, fade); return 0; } @@ -12224,6 +12259,21 @@ static int32_t apiOsClock(lua_State *L) { } +// os.time([table]) In a deterministic run the bare call answers with the virtual clock's seconds +// from the option's seed, so a game seeding from the wall clock lands on the same numbers every +// run; a call with a table is a date conversion, and Lua's own answers it. +static int32_t apiOsTime(lua_State *L) { + if (lua_gettop(L) == 0) { + lua_pushinteger(L, (lua_Integer)_global.conf->deterministicStep + (lua_Integer)(utilTicks() / MS_PER_SECOND)); + return 1; + } + lua_pushvalue(L, lua_upvalueindex(1)); + lua_insert(L, 1); + lua_call(L, lua_gettop(L) - 1, LUA_MULTRET); + return lua_gettop(L); +} + + // overlayBox(x1, y1, x2, y2) Outline only. static int32_t apiOverlayBox(lua_State *L) { int32_t x1 = 0; @@ -16836,7 +16886,10 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co // refreshes continuously. Emptying them per rendered frame therefore drew the GUIs and // the 2D particles only on the frames the script happened to run on, which is a menu that // blinks over a grid that does not. - if (!_global.frozen && (utilTicks() > frameClock)) { + // + // The throttle is inclusive: on the virtual clock every loop iteration is exactly one + // step, and a strict comparison ran the script every other iteration, a disc frame apart. + if (!_global.frozen && (utilTicks() >= frameClock)) { intReturn = OVERLAY_NOT_UPDATED; _global.guiDrawCount = 0; _global.overlayLayerCount = 0; diff --git a/src/videoPlayer.c b/src/videoPlayer.c index 304a96538..fb54ed6c2 100644 --- a/src/videoPlayer.c +++ b/src/videoPlayer.c @@ -2600,36 +2600,28 @@ int64_t videoUpdate(int32_t playerHandle, SDL_Texture **texture) { _resetClock(v, now); } else { if (v->playing) { - if (utilIsDeterministic()) { - // Decoding has a clock of its own, which no virtual clock reaches, so a - // reproducible run steps the disc a frame at a time from wherever it is - // parked. A paused disc and a search still settle where they always did. - v->frame++; - if (v->frame >= count) { + // Where in the video should we be? Follow the audio when there is any. A + // deterministic run follows the virtual clock instead: the audio's own clock is + // real time, which no virtual clock reaches, and the frame loop is paced as a + // real one is, so a frame stays on for as many frames as its time is worth. + if (utilIsDeterministic() || (v->audioSourceCount == 0)) { + elapsed = (int64_t)(now - v->startTicks) + v->startTime; + } else { + elapsed = _audioClock(v, now); + } + // Advance to the last frame whose presentation time has arrived. + next = v->frame + 1; + while ((next < count) && (_frameTime(v, next) <= elapsed)) { + v->frame = next; + next++; + } + // Past the end of the last frame? Loop. + if (next >= count) { + lastDuration = (int64_t)(MS_PER_SECOND * (double)v->fps.den / (double)v->fps.num); + if (elapsed >= _frameTime(v, count - 1) + lastDuration) { v->frame = 0; _resetClock(v, now); } - } else { - // Where in the video should we be? Follow the audio when there is any. - if (v->audioSourceCount > 0) { - elapsed = _audioClock(v, now); - } else { - elapsed = (int64_t)(now - v->startTicks) + v->startTime; - } - // Advance to the last frame whose presentation time has arrived. - next = v->frame + 1; - while ((next < count) && (_frameTime(v, next) <= elapsed)) { - v->frame = next; - next++; - } - // Past the end of the last frame? Loop. - if (next >= count) { - lastDuration = (int64_t)(MS_PER_SECOND * (double)v->fps.den / (double)v->fps.num); - if (elapsed >= _frameTime(v, count - 1) + lastDuration) { - v->frame = 0; - _resetClock(v, now); - } - } } } } diff --git a/testScripts/Menu.sh b/testScripts/Menu.sh new file mode 100755 index 000000000..d833363a3 --- /dev/null +++ b/testScripts/Menu.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +cd "$(dirname "$0")" +"/home/scott/claude/singe/.builddir/linux-gcc/x86_64/singe/Singe-v3.00-Linux-x86_64" -k -w -v Singe/menuBackground.mkv Singe/Menu.singe diff --git a/testScripts/README.md b/testScripts/README.md index 72b18059f..2a2235391 100644 --- a/testScripts/README.md +++ b/testScripts/README.md @@ -68,3 +68,4 @@ screenshot or two and quits by itself; the results are in screenshots/. | scene49.singe | 49 | 48.5 | Input over the disc: every device the engine can see on one page -- the four gamepad slots with the name, the six axis values and the fifteen button states of whatever is in each, the mice ManyMouse found with their names, the last mouse position and how many times `SWITCH_MOUSE_DISCONNECT` has fired, the keyboard mode, and the switches the mappings are currently letting through -- with `DEAD_ZONE`, `TRIGGER_THRESHOLD` (raw and as a per cent of full travel) and `joyMouseIsEnabled()` across the top. The phases are timed on the wall clock (shots at 2, 6, 10 and 16 seconds, quitting at 18) so a device can be attached, pressed and unplugged while it runs; the LD_PRELOAD harness in the scratchpad drives an SDL virtual joystick through exactly that. Run plain, with `--joymouse --js_range 20`, with `--gamepad_reorder 10`, with `--trigger_threshold 99.5`, with `--mapjoysticks=false`, with `--manymouse --absolutes_only`, with a `gamecontrollerdb.txt` beside it, and with a `controls.cfg` setting `DEAD_ZONES` | | scene50.singe | 50 | 48.6 | Subtitles and the disc picture over the disc: `testScripts/subtitles.srt` (four cues, one of them two lines and one carrying a `<bracket>` and an `&` that must reach the screen as text) parsed into disc frames at the disc's own frame rate and shown through the shipped `Singe/subtitle.rml`, with `testScripts/broken.srt` refused (`srtLoad` answers false and loads nothing); the disc searched to a known frame before every shot so a cue is read off the disc's frame number and not the wall clock; `srtPosition` at 15 and 80 per cent; `overlayBanner` taking the cue's place for thirty drawn frames and then giving it back; `srtEnable` off and on; `srtClear`; `vldpSetLuma` at 0, 8, 4 and off; `vldpSetBlend` on, off and together with the luma; and `vldpFlash`, asked for and shot in the same tick because it lasts one drawn frame. 23 screenshots | | scene51.singe | 51 | 48.6 | Sprites, in-memory assets and music over the disc: `spriteRotateFrame` turning frames 1 to 4 of `testScripts/arrows.png` (a four frame strip whose arrows have a white notch, so a rotation is readable) at 0, 45, 135 and 225 degrees, drawn by `spriteDrawRotatedFrame` plain, at 1.5 and at 2 by 0.75, beside the same frame through `spriteDrawFrame` which ignores both; `spriteScale` before a rotation, so the pair honours the sprite's own scale; an out of range frame falling back to frame 1; `spriteDrawGrid` taking three rectangles out of the same file loaded as one wide still and a fourth out of a sprite `spriteLoadData` built from the file's bytes; `spriteGetFrames` on all three; `controllerHowMany`; `setOverlayOpacity` at 96 and back to 255; and the music family over `testScripts/tune.mp3` (six seconds of 330 Hz, so the built-in mp3 decoder is exercised) -- play looping, `musicSetVolume` at 128 and 16, `soundSetVolume` at 8 with a `soundLoadData` sound played through it to show the two volumes do not move each other, pause, resume, a 250 ms fade out, a play once and an unload. 16 screenshots | +| scene99.singe | 52 | 50 | Formats: a lossless JPEG XL of crate.png (crate.jxl) drawn beside the PNG, which it must match, and the chiptune and tracker decoders played as music and as a sound (chip.nsf, tracker.xm, tracker.it, tracker.s3m); the run's log says what played | diff --git a/testScripts/author/adventure2d.game b/testScripts/author/adventure2d.forge similarity index 100% rename from testScripts/author/adventure2d.game rename to testScripts/author/adventure2d.forge diff --git a/testScripts/author/adventure3d.game b/testScripts/author/adventure3d.forge similarity index 98% rename from testScripts/author/adventure3d.game rename to testScripts/author/adventure3d.forge index d4cad6ffe..6062449de 100644 --- a/testScripts/author/adventure3d.game +++ b/testScripts/author/adventure3d.forge @@ -2,7 +2,7 @@ -- Fandango's shape. The rules, the dialogue, the verbs, and the parser are the 2D game's own, -- taken from its description; only the types and the rooms are new. That the rules do not -- change between a painting and a scene is the point. -local base = dofile("testScripts/author/adventure2d.game") +local base = dofile("testScripts/author/adventure2d.forge") return { title = "Author test: adventure in 3D", diff --git a/testScripts/author/bowling.game b/testScripts/author/bowling.forge similarity index 100% rename from testScripts/author/bowling.game rename to testScripts/author/bowling.forge diff --git a/testScripts/author/breakout.game b/testScripts/author/breakout.forge similarity index 100% rename from testScripts/author/breakout.game rename to testScripts/author/breakout.forge diff --git a/testScripts/author/fmv.game b/testScripts/author/fmv.forge similarity index 100% rename from testScripts/author/fmv.game rename to testScripts/author/fmv.forge diff --git a/testScripts/author/fps.game b/testScripts/author/fps.forge similarity index 100% rename from testScripts/author/fps.game rename to testScripts/author/fps.forge diff --git a/testScripts/author/gunvideo.game b/testScripts/author/gunvideo.forge similarity index 100% rename from testScripts/author/gunvideo.game rename to testScripts/author/gunvideo.forge diff --git a/testScripts/author/mash.forge b/testScripts/author/mash.forge new file mode 100644 index 000000000..cdf7c9788 --- /dev/null +++ b/testScripts/author/mash.forge @@ -0,0 +1,34 @@ +-- Mash and hold on the branching track: one window wants RIGHT six times, the next wants UP +-- held for twenty frames, the last wants RIGHT mashed again and gets too few. +return { + title = "Author test: mash and hold", + layers = { { kind = "disc", file = "Singe/menuBackground.mkv", start = 100 }, { kind = "overlay" } }, + vars = { taken = 0, missed = 0, wanted = 0, opened = "" }, + + types = { + dirk = { look = { kind = "none" }, behaviours = { { kind = "branching", track = "moves" } } } + }, + + rooms = { + { name = "cavern", + entities = { { type = "dirk", id = "dirk", x = 0, y = 0 } }, + tracks = { + { name = "moves", key = "frame", + branches = { { from = 120, to = 160, move = "RIGHT", mash = 6, success = 200, fail = 200 }, + { from = 200, to = 240, move = "UP", hold = 20, success = 280, fail = 280 }, + { from = 280, to = 320, move = "RIGHT", mash = 6, success = 340, fail = 340 } } } + } } + }, + + rules = { + { note = "A window opens: remember what it wants", + on = "branchOpen", + act = { { "setVar", name = "wanted", value = "event.mash or event.hold or 0" }, { "setVar", name = "opened", value = "opened .. event.move .. \" \"" } } }, + { note = "Made it", + on = "branchTaken", + act = { { "addVar", name = "taken", amount = 1 } } }, + { note = "Missed it", + on = "branchMissed", + act = { { "addVar", name = "missed", amount = 1 } } } + } +} diff --git a/testScripts/author/maze.game b/testScripts/author/maze.forge similarity index 100% rename from testScripts/author/maze.game rename to testScripts/author/maze.forge diff --git a/testScripts/author/overlayBits.forge b/testScripts/author/overlayBits.forge new file mode 100644 index 000000000..a6fbe7369 --- /dev/null +++ b/testScripts/author/overlayBits.forge @@ -0,0 +1,47 @@ +-- The pieces the ActionMax port asked for, over the menu video: a text look with a font, size, +-- and anchor of its own; a sprite drawn in copies (a row of coins); a crosshair on the pointer; +-- the disc paused and played by rules; and pointerX in an expression. +return { + title = "Author test: overlay bits", + players = 1, + size = { w = 720, h = 480 }, + layers = { { kind = "disc", file = "Singe/menuBackground.mkv", start = 100 }, { kind = "overlay" } }, + vars = { coins = 3, side = "", held = 0 }, + + types = { + title = { look = { kind = "text", text = "OVERLAY BITS", r = 255, g = 220, b = 80, font = "Singe/FreeSansBold.ttf", size = 36, anchor = "top" } }, + foot = { look = { kind = "text", text = "standing on the bottom", r = 200, g = 200, b = 255, anchor = "feet" } }, + mid = { look = { kind = "text", text = "", r = 255, g = 255, b = 255, anchor = "centre" } }, + coins = { look = { kind = "sprite", file = "Forge/kit/coin.png", step = 40 }, vars = { copies = 3 } }, + cross = { look = { kind = "box", w = 9, h = 9, r = 255, g = 80, b = 80 }, behaviours = { { kind = "pointer", player = 1 } } } + }, + + rooms = { + { name = "board", + entities = { + { type = "title", id = "title", x = 360, y = 10 }, + { type = "foot", id = "foot", x = 360, y = 480 }, + { type = "mid", id = "mid", x = 360, y = 240 }, + { type = "coins", id = "coins", x = 40, y = 40 }, + { type = "cross", id = "cross", x = 0, y = 0 } + } } + }, + + rules = { + { note = "The coins follow the var", + on = "frame", + act = { { "setVar", entity = "coins", name = "copies", value = "coins" } } }, + { note = "Which side the pointer is on", + on = "frame", when = { { "test", expr = "pointerX(1) < 360" } }, + act = { { "setVar", name = "side", value = "\"left\"" }, { "setText", entity = "mid", text = "\"pointer on the \" .. side" } } }, + { note = "Or the other", + on = "frame", when = { { "test", expr = "pointerX(1) >= 360" } }, + act = { { "setVar", name = "side", value = "\"right\"" }, { "setText", entity = "mid", text = "\"pointer on the \" .. side" } } }, + { note = "SPACE holds the disc", + on = "pressed", key = "SPACE", + act = { { "discPause" }, { "setVar", name = "held", value = "frame" } } }, + { note = "RETURN lets it go", + on = "pressed", key = "RETURN", + act = { { "discPlay" } } } + } +} diff --git a/testScripts/author/painted.game b/testScripts/author/painted.forge similarity index 100% rename from testScripts/author/painted.game rename to testScripts/author/painted.forge diff --git a/testScripts/author/platformer.game b/testScripts/author/platformer.forge similarity index 100% rename from testScripts/author/platformer.game rename to testScripts/author/platformer.forge diff --git a/testScripts/author/platformer3d.game b/testScripts/author/platformer3d.forge similarity index 100% rename from testScripts/author/platformer3d.game rename to testScripts/author/platformer3d.forge diff --git a/testScripts/author/pool.game b/testScripts/author/pool.forge similarity index 100% rename from testScripts/author/pool.game rename to testScripts/author/pool.forge diff --git a/testScripts/author/qte.game b/testScripts/author/qte.forge similarity index 100% rename from testScripts/author/qte.game rename to testScripts/author/qte.forge diff --git a/testScripts/author/racing.game b/testScripts/author/racing.forge similarity index 100% rename from testScripts/author/racing.game rename to testScripts/author/racing.forge diff --git a/testScripts/author/railshooter.game b/testScripts/author/railshooter.forge similarity index 100% rename from testScripts/author/railshooter.game rename to testScripts/author/railshooter.forge diff --git a/testScripts/author/rhythm.game b/testScripts/author/rhythm.forge similarity index 100% rename from testScripts/author/rhythm.game rename to testScripts/author/rhythm.forge diff --git a/testScripts/author/shmup.game b/testScripts/author/shmup.forge similarity index 100% rename from testScripts/author/shmup.game rename to testScripts/author/shmup.forge diff --git a/testScripts/author/space.game b/testScripts/author/space.forge similarity index 100% rename from testScripts/author/space.game rename to testScripts/author/space.forge diff --git a/testScripts/author/tail.game b/testScripts/author/tail.forge similarity index 100% rename from testScripts/author/tail.game rename to testScripts/author/tail.forge diff --git a/testScripts/author/thirdperson.game b/testScripts/author/thirdperson.forge similarity index 100% rename from testScripts/author/thirdperson.game rename to testScripts/author/thirdperson.forge diff --git a/testScripts/author/towerdefence.game b/testScripts/author/towerdefence.forge similarity index 100% rename from testScripts/author/towerdefence.game rename to testScripts/author/towerdefence.forge diff --git a/testScripts/author/vocab.forge b/testScripts/author/vocab.forge new file mode 100644 index 000000000..92c301272 --- /dev/null +++ b/testScripts/author/vocab.forge @@ -0,0 +1,32 @@ +-- A game with a vocabulary of its own: the bob behaviour, the bobbing condition, and the setBob +-- action live in vocab.singe beside this file, not in Forge. +return { + title = "Author test: a game's own vocabulary", + players = 1, + size = { w = 720, h = 480 }, + layers = { { kind = "overlay" } }, + vocabulary = "vocab.singe", + vars = { seen = 0 }, + + types = { + buoy = { look = { kind = "box", w = 30, h = 30, r = 80, g = 200, b = 255 }, behaviours = { { kind = "bob", height = 40, speed = 3 } } }, + rock = { look = { kind = "box", w = 30, h = 30, r = 120, g = 120, b = 120 } } + }, + + rooms = { + { name = "sea", + entities = { + { type = "buoy", id = "buoy", x = 200, y = 240 }, + { type = "rock", id = "rock", x = 500, y = 240 } + } } + }, + + rules = { + { note = "Counts the frames the buoy bobs", + on = "frame", when = { { "bobbing", entity = "buoy" } }, + act = { { "setVar", name = "seen", value = "seen + 1" } } }, + { note = "SPACE makes it bob higher", + on = "pressed", key = "SPACE", + act = { { "setBob", entity = "buoy", height = 100 } } } + } +} diff --git a/testScripts/author/vocab.singe b/testScripts/author/vocab.singe new file mode 100644 index 000000000..fa130be80 --- /dev/null +++ b/testScripts/author/vocab.singe @@ -0,0 +1,47 @@ +-- A game's own vocabulary: a behaviour, a condition, and an action this game alone knows, +-- written as Author.singe writes its own manifest entries. The description names this file +-- (vocabulary = "vocab.singe"); nothing else in Forge has heard of a bob. + +-- A bob: the entity rises and falls on a sine wave about where it was put. +AUTHOR.behaviours.bob = { + help = "Rises and falls about its place, height pixels either way, speed times a second.", + params = { height = "number", speed = "number" }, + attach = function(instance, params) + local _, y = authorPosition(instance) + + instance.bob = { height = params.height or 20, speed = params.speed or 2, base = y, at = 0 } + end, + step = function(instance, dt) + local bob = instance.bob + local x, _, z = authorPosition(instance) + + bob.at = bob.at + dt * bob.speed + nodeSetPosition(instance.node, x, bob.base + math.sin(bob.at) * bob.height, z) + end +} + +-- Whether an instance bobs: a condition of this game's own. +AUTHOR.conditions.bobbing = { + help = "An instance has a bob.", + params = { entity = "entity" }, + emit = function(p) return "vocabBobbing(" .. p.entity .. ")" end +} + +-- Sets how high an instance bobs: an action of this game's own. +AUTHOR.actions.setBob = { + help = "Changes how high an instance bobs.", + params = { entity = "entity", height = "number" }, + emit = function(p) return "vocabSetBob(" .. p.entity .. ", " .. p.height .. ")" end +} + + +function vocabBobbing(instance) + return (instance ~= nil) and (instance.bob ~= nil) +end + + +function vocabSetBob(instance, height) + if instance and instance.bob then + instance.bob.height = height + end +end diff --git a/testScripts/author/waves.game b/testScripts/author/waves.forge similarity index 100% rename from testScripts/author/waves.game rename to testScripts/author/waves.forge diff --git a/testScripts/chip.nsf b/testScripts/chip.nsf new file mode 100644 index 000000000..a14994e02 --- /dev/null +++ b/testScripts/chip.nsf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a8015d01d67f78c4fd6f5dd02a5dd04239e5aeeb2f90f8717050b4ed85effbe5 +size 749 diff --git a/testScripts/crate.jxl b/testScripts/crate.jxl new file mode 100644 index 000000000..109c85d56 --- /dev/null +++ b/testScripts/crate.jxl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c5f4e26054194e214cd1709b42bcc3a009ab7501ddb397b2108197af4b3c31e +size 849 diff --git a/testScripts/games.dat b/testScripts/games.dat index 011831d96..405733f18 100644 --- a/testScripts/games.dat +++ b/testScripts/games.dat @@ -541,4 +541,15 @@ GAMES = { DEVELOPER = "Test", PUBLISHER = "Test", }, + { + TITLE = "Formats: JPEG XL and Chiptunes", + SCRIPT = "testScripts/scene99.singe", + VIDEO = "Singe/menuBackground.mkv", + DESCRIPTION = "A JPEG XL picture (crate.jxl, lossless from crate.png) drawn beside its PNG, which it must match pixel for pixel, and the chiptune and tracker decoders exercised as music and as a sound: game-music-emu's NSF (chip.nsf), libxmp's XM, IT and S3M (tracker.*).", + YEAR = 2026, + GENRE = "Test", + PLATFORM = "Singe", + DEVELOPER = "Test", + PUBLISHER = "Test", + }, } diff --git a/testScripts/ports/README b/testScripts/ports/README new file mode 100644 index 000000000..26a434d1e --- /dev/null +++ b/testScripts/ports/README @@ -0,0 +1,30 @@ +Port comparisons (FORGE.md section 14): each pair runs an original game from the test library +and its Forge port with the same scripted inputs, on the deterministic clock, and prints TRACE +lines; the port passes when the two traces agree. They need the library beside the repo +(SINGE_LIBRARY, or ~/claude/singetest) and are not part of the reference sweep. + + ActionMax: actionMaxOriginal.singe and actionMaxPort.singe, with + -v <library>/ActionMax/frame_<game>.txt and PORT_GAME=<game> + (38AmbushAlley, BlueThunder, Hydrosub2021, PopsGhostly, SonicFury); + the descriptions come from util/forgePortActionMax.py. + + compare.sh <family> <game> runs a pair and says whether the traces agree: + testScripts/ports/compare.sh actionMax 38AmbushAlley + + Karis: karisOriginal.singe and karisPort.singe, driven by karisDrive.singe, with + -v the video the game's games.dat names, PORT_GAME=<directory> and + PORT_SCRIPT=<main script>; the descriptions come from util/forgePortKaris.lua: + testScripts/ports/compare.sh karis TimeGal/Timegal.singe + + ALG: algOriginal.singe and algPort.singe, driven by algDrive.singe, for the American + Laser Games titles of the Hypseus library (Mad Dog McCree, Mad Dog II, Crime + Patrol, Drug Wars, The Last Bounty Hunter, Space Pirates, both Who Shot Johnny + Rocks, and the typing edition of Mad Dog II), with the title's folder as the game: + testScripts/ports/compare.sh alg maddog-hd + PORT_FRAMES=<n> sets the run's length in engine frames (15000 by default) and + PORT_PERFECT=1 plays without a wrong or a missed shot, to walk the whole game. + The views hand the drive each level's shoot window and the first hitbox of the + frame (aimed at the overlap with the next frame's box, since a press is judged a + frame later), the targets of the games' menus and puzzles, and, for the typing + edition, the key the game waits for; the drive shoots, misses on schedule, coins + and starts, continues once, and types. diff --git a/testScripts/ports/actionMaxInputs.singe b/testScripts/ports/actionMaxInputs.singe new file mode 100644 index 000000000..b6e05785e --- /dev/null +++ b/testScripts/ports/actionMaxInputs.singe @@ -0,0 +1,36 @@ +-- The inputs both the original ActionMax emulator and its Forge port are driven with, by frame +-- of the script's own clock, so the two can be compared move for move (FORGE.md section 14.1). +-- A shot is a pointer position and a trigger pull. The title takes one pull, the menu one on +-- the left half (a standard game), and then the picture is shot at from a dozen places while +-- it plays. +PORT_INPUTS = { + { at = 40, x = 300, y = 200 }, -- Title: pull to start. + { at = 140, x = 100, y = 240 }, -- Menu: the left half, a standard game. +} +-- In .38 Ambush Alley the picture flickers with the sensor -- targets -- at 240,140 from disc +-- frame 2968, at 220,140 from 2591, and at 220,80 from 2577, all left of the sensor's own corner, +-- which the game reaches about 2740 frames in. Most shots go there, a few elsewhere, so good +-- and bad judgments both come up. +local TARGETS = { { x = 240, y = 140 }, { x = 220, y = 140 }, { x = 220, y = 80 } } +for shot = 1, 40 do + local target = TARGETS[(shot % 3) + 1] + local input = { at = 2740 + shot * 28, x = target.x, y = target.y } + + if shot % 4 == 0 then + input.x, input.y = 40 + (shot * 97) % 240, 20 + (shot * 53) % 150 + end + PORT_INPUTS[#PORT_INPUTS + 1] = input +end +PORT_LAST = 2740 + 41 * 28 + 200 + + +-- The input due this frame, if any. +function portInputAt(frame) + for _, input in ipairs(PORT_INPUTS) do + if input.at == frame then + return input + end + end + + return nil +end diff --git a/testScripts/ports/actionMaxOriginal.singe b/testScripts/ports/actionMaxOriginal.singe new file mode 100644 index 000000000..99816b748 --- /dev/null +++ b/testScripts/ports/actionMaxOriginal.singe @@ -0,0 +1,52 @@ +-- The original ActionMax emulator, driven by the shared inputs, tracing what a port must match: +-- its state, its counts, and the disc frame at every input and every change of state. Run +-- with -v <library>/ActionMax/frame_38AmbushAlley.txt; PORT_GAME names another of the five. +local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest/ported") +local game = os.getenv("PORT_GAME") or "38AmbushAlley" +local script = library .. "/ActionMax/" .. game .. ".singe" + +-- The inputs sit beside this script, wherever the engine was started from. +local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "") + +dofile(here .. "actionMaxInputs.singe") + +-- The emulator finds its files by its own script's path, and draws sprites in the 2.10 order, +-- which its games.dat entry would have said. +SINGE_LEGACY_SPRITE_ARGS = true +local realScriptPath = singeGetScriptPath +singeGetScriptPath = function() return script end +dofile(script) +singeGetScriptPath = realScriptPath + +local realUpdate = onOverlayUpdate +local frames = 0 +local lastState = nil + + +function onOverlayUpdate() + frames = frames + 1 + local input = portInputAt(frames) + + if input then + onMouseMoved(input.x, input.y, 0, 0) + onInputPressed(SWITCH_BUTTON3) + debugPrint(string.format("TRACE input %d at %d,%d: state %d frame %d shots %d good %d bad %d", frames, input.x, input.y, currentState, discGetFrame(), shotsFired, shotsGood, shotsBad)) + end + local r = realUpdate() + + if os.getenv("PORT_SENSOR_TRACE") and (frames >= tonumber(os.getenv("PORT_SENSOR_TRACE"))) and (frames < tonumber(os.getenv("PORT_SENSOR_TRACE")) + 12) then + debugPrint(string.format("SENSOR frame %d disc %d pulled %d spot %s/%s gun %s/%s mouse %d,%d", frames, discGetFrame(), triggerPulled, tostring(sensorLastState), tostring(sensorState), tostring(gunLastState), tostring(gunState), mouseX, mouseY)) + end + -- The startup and setup states are the emulator's own housekeeping; the title is the first + -- state a port has. + if (currentState ~= lastState) and (currentState >= 2) then + lastState = currentState + debugPrint(string.format("TRACE state %d from frame %d: disc %d shots %d good %d bad %d", currentState, frames, discGetFrame(), shotsFired, shotsGood, shotsBad)) + end + if frames >= PORT_LAST then + debugPrint(string.format("TRACE end: state %d disc %d shots %d good %d bad %d", currentState, discGetFrame(), shotsFired, shotsGood, shotsBad)) + singeQuit() + end + + return r +end diff --git a/testScripts/ports/actionMaxPort.singe b/testScripts/ports/actionMaxPort.singe new file mode 100644 index 000000000..c23c7c213 --- /dev/null +++ b/testScripts/ports/actionMaxPort.singe @@ -0,0 +1,52 @@ +-- The Forge port of an ActionMax game, driven by the same inputs as the original and tracing +-- the same things, in the original's terms: its state numbers stand for the port's rooms. +local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest/ported") +local game = os.getenv("PORT_GAME") or "38AmbushAlley" +local STATES = { startup = 0, title = 2, menu = 3, intro = 4, playing = 5, gameOver = 6 } + +dofile("Forge/AuthorCompile.singe") +-- The inputs sit beside this script, wherever the engine was started from. +local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "") + +dofile(here .. "actionMaxInputs.singe") +dofile(authorBuild(library .. "/ActionMax/" .. game .. ".forge", singeGetDataPath() .. game .. ".singe")) + +local realUpdate = onOverlayUpdate +local frames = 0 +local lastRoom = nil + + +local function counts() + return string.format("shots %d good %d bad %d", AUTHOR_VARS.shots, AUTHOR_VARS.good, AUTHOR_VARS.bad) +end + + +function onOverlayUpdate() + frames = frames + 1 + local input = portInputAt(frames) + + if input then + authorSetPointer(1, input.x, input.y, true) + authorSwitchDown(SWITCH_BUTTON3) + authorSwitchUp(SWITCH_BUTTON3) + debugPrint(string.format("TRACE input %d at %d,%d: state %d frame %d %s", frames, input.x, input.y, STATES[AUTHOR_ROOM.name] or -1, discGetFrame(), counts())) + end + local r = realUpdate() + + if os.getenv("PORT_SENSOR_TRACE") and (frames >= tonumber(os.getenv("PORT_SENSOR_TRACE"))) and (frames < tonumber(os.getenv("PORT_SENSOR_TRACE")) + 12) then + local sensor = authorEntity("sensor") + local gx, gy = authorPointer(1) + + debugPrint(string.format("SENSOR frame %d disc %d pulled %d spot %s gun %s now %s/%s pointer %d,%d", frames, discGetFrame(), sensor.pulled, tostring(sensor.spotWas), tostring(sensor.gunWas), authorPixelState(312, 205, 127, 20), authorPixelState(gx, gy, 127, 20), gx, gy)) + end + if AUTHOR_ROOM.name ~= lastRoom then + lastRoom = AUTHOR_ROOM.name + debugPrint(string.format("TRACE state %d from frame %d: disc %d %s", STATES[AUTHOR_ROOM.name] or -1, frames, discGetFrame(), counts())) + end + if frames >= PORT_LAST then + debugPrint(string.format("TRACE end: state %d disc %d %s", STATES[AUTHOR_ROOM.name] or -1, discGetFrame(), counts())) + singeQuit() + end + + return r +end diff --git a/testScripts/ports/algDrive.singe b/testScripts/ports/algDrive.singe new file mode 100644 index 000000000..aeb12f356 --- /dev/null +++ b/testScripts/ports/algDrive.singe @@ -0,0 +1,263 @@ +-- Drives an American Laser Games title (the original or its port) from what the game shows, +-- the same way for both. The driver file defines what it takes: a view() of the game's state +-- and algPress(switch, device, down) / algPointer(device, x, y) to press a gun's trigger or +-- put its pointer; this file decides. The trace lines it prints are what compare.sh diffs. +-- +-- The policy: the first gun skips the intro on frame 300 of the run and joins at the player +-- select; at the town select it points at the first unbeaten quadrant and shoots; in a level +-- it shoots the first hitbox of the current window's frame three frames into the window (a +-- miss well outside it on every fifth move, no shot on every seventh), reloads by pointing +-- offscreen when its ammo is out, and takes the continue. + +ALG_FRAMES = tonumber(os.getenv("PORT_FRAMES")) or 60000 + +local answered = {} -- Windows shot at, by "level/move/lives". +local tried = {} -- Moves met before, by "level/move". +local frames = 0 +local joined = false +local coined = false +local continued = false +local continueCoined = false +local seeks = 0 +SWITCH_RETURN_KEY = 13 -- The return key's keysym, as the typing edition names it. +local wrongTyped = {} -- Moves already given their wrong letter. +local pendingRelease = nil -- { switch, device } pressed this frame, let go the next. + + +function algTraceSeek(frame) + seeks = seeks + 1 + debugPrint(string.format("TRACE seek %d", frame)) +end + + +-- PORT_PERFECT=1 plays without a wrong or a missed shot, to walk the whole game in one run. +local perfect = (os.getenv("PORT_PERFECT") == "1") + +-- PORT_SERVICE=1 opens the service screen at the attract loop before the game, steps the +-- pointer down it a row at a time with a shot on each (or, on a screen the stick drives, a +-- press and a step down in turn), then leaves it; the trace carries the option under the +-- cursor and every dip while the screen shows. PORT_SHOTS=1 takes a screenshot at the +-- attract loop, on the service screen, in a level just after a shot, at the continue, and +-- at the board, each once, named for the moment. +local walkService = (os.getenv("PORT_SERVICE") == "1") +local shots = (os.getenv("PORT_SHOTS") == "1") +local walked = false +local walkStep = 0 +local walkAt = 0 +local taken = {} +local shotAt = nil + + +local function shot(name) + if shots and not taken[name] then + taken[name] = true + singeScreenshot("shot-" .. name) + end +end + + +local function wrongMove(view) + return (not perfect) and (view.move % 5 == 0) and not view.retry +end + + +local function missedMove(view) + return (not perfect) and (view.move % 7 == 0) and (view.move % 5 ~= 0) and not view.retry +end + + +-- Presses a gun's trigger for one frame. +local function tap(switch, device) + algPress(switch, device, true) + pendingRelease = { switch, device } +end + + +-- view: { screen, step, move, frame, frames, score, lives, ammo, active, window = { from, to }, +-- box = { x1, y1, x2, y2 } (the first hitbox of this frame, in the game's pointer +-- coordinates), quadrants = { {x, y}, ... } (the town select's unbeaten choices), +-- offscreen = { x, y } (a pointer position that counts as off the picture), +-- spare (bullets the game never judges, kept back), reloadSwitch (what reloads) } +function algDrive(view) + if pendingRelease then + algPress(pendingRelease[1], pendingRelease[2], false) + pendingRelease = nil + end + frames = frames + 1 + local line = string.format("TRACE screen %s step %s move %s score %s lives %s ammo %s", tostring(view.screen), tostring(view.step), tostring(view.move), tostring(view.score), tostring(view.lives), tostring(view.ammo)) + + if view.service then + line = line .. " opt " .. tostring(view.opt) .. " dips " .. tostring(view.dips) + end + if line ~= lastLine then + local detail = "" + + -- PORT_DEBUG=1 adds what the drive decides by: the window, the spare, the free shot, and + -- the pause. + if os.getenv("PORT_DEBUG") == "1" then + detail = string.format(" win %s spare %s free %s paused %s", view.window and (view.window[1] .. "-" .. view.window[2]) or "nil", tostring(view.spare), tostring(view.free), tostring(view.paused)) + end + debugPrint(line .. detail .. " disc " .. tostring(view.frame)) + lastLine = line + end + if frames >= ALG_FRAMES then + debugPrint("TRACE end after " .. frames .. " frames") + singeQuit() + return + end + -- A typing game (the typing edition of Mad Dog II): the key the view names is released, one + -- a frame; with misses on, every fifth move gets a wrong letter first and every seventh is + -- left untyped (a death), each once per move. The continue is taken once, by the return key. + if view.typed then + if view.continuing then + if (not continued) and (frames % 30 == 0) then + continued = true + algKey(SWITCH_RETURN_KEY) + end + return + end + if view.typing then + local moveKey = tostring(view.screen) .. "/" .. tostring(view.move) + + if (not perfect) and (view.move % 7 == 0) and (view.move % 5 ~= 0) and (view.typing ~= SWITCH_RETURN_KEY) then + -- Untyped: the game holds the disc and takes the life. + return + end + if (not perfect) and (view.move % 5 == 0) and (view.typing ~= SWITCH_RETURN_KEY) and not wrongTyped[moveKey] then + wrongTyped[moveKey] = true + algKey((view.typing == 122) and 121 or 122) + return + end + algKey(view.typing) + end + return + end + -- The service screen, walked once before the game. + if walkService and not walked then + if view.service then + if (frames - walkAt) >= 30 then + walkAt = frames + walkStep = walkStep + 1 + if walkStep == 3 then + shot("service") + end + if view.serviceKeys then + -- The stick's screen: a press on the option, then a step down; the last option + -- is the way out. + if walkStep % 2 == 1 then + tap(SWITCH_BUTTON1, 0) + else + tap(SWITCH_DOWN, 0) + end + if walkStep > 26 then + walked = true + end + elseif walkStep <= 9 then + algPointer(0, view.width * 0.5, view.height * (walkStep - 0.5) / 9) + tap(SWITCH_BUTTON3, 0) + else + walked = true + tap(SWITCH_BUTTON1, 0) + end + end + return + elseif view.intro and (frames > 200) then + walkAt = frames + tap(SWITCH_SERVICE, nil) + return + end + end + if shots and view.intro and (frames == 330) then + shot("attract") + end + if shots and view.continuing then + shot("continue") + end + if shots and view.board then + shot("board") + end + if shotAt and (frames == shotAt + 3) then + shot("level") + end + -- The player select: START for the first gun, once. + if (view.screen == -2) and not joined and (frames > 60) then + joined = true + tap(SWITCH_START1, 0) + return + end + -- The intro: a coin, then START begins a game (the tutorial or the first level by dip). + -- (A game with no player select, the one-player Johnny Rock, counts as joined from the start.) + if view.intro and (joined or view.noSelect) then + if not coined and (frames > 200) then + coined = true + tap(SWITCH_COIN1, nil) + return + end + if coined and (frames > 300) and (frames % 40 == 0) then + tap(SWITCH_START1, 0) + end + return + end + -- The continue: taken once, with a coin first (Mad Dog II's continue wants a credit). + if view.continuing and not continued and (frames % 30 == 0) then + if not continueCoined then + continueCoined = true + tap(SWITCH_COIN1, nil) + return + end + continued = true + tap(SWITCH_START1, 0) + return + end + -- The town select: the pointer to the first unbeaten quadrant, then the trigger. + if (view.screen == 100) and view.quadrants and view.quadrants[1] and (view.step == 2) and (frames % 20 == 0) then + algPointer(0, view.quadrants[1][1], view.quadrants[1][2]) + tap(SWITCH_BUTTON3, 0) + return + end + -- A screen with targets and no windows (the signpost, the mine, Mad Dog II's menu, item, and + -- skull): the first target shot, every twenty frames, while its state waits for shots (the + -- running state unless the view names another). + if view.targets and view.targets[1] and (view.step == (view.targetStep or 2)) and (frames % 20 == 0) then + if (view.ammo ~= nil) and (view.ammo <= (view.spare or 0)) and view.offscreen then + algPointer(0, view.offscreen[1], view.offscreen[2]) + tap(view.reloadSwitch or SWITCH_BUTTON3, 0) + else + algPointer(0, view.targets[1][1], view.targets[1][2]) + tap(SWITCH_BUTTON3, 0) + end + return + end + -- A level: ammo out (or down to the spare an edition never judges) means a reload by pointing + -- offscreen and pressing the switch the view names (the trigger, or a reload button). + if view.window and (view.ammo ~= nil) and (view.ammo <= (view.spare or 0)) and view.offscreen then + algPointer(0, view.offscreen[1], view.offscreen[2]) + tap(view.reloadSwitch or SWITCH_BUTTON3, 0) + return + end + if view.window and view.box then + -- (A move is answered once per life, and once more per life in the game the continue + -- starts over.) + local moveKey = tostring(view.screen) .. "/" .. tostring(view.move) + local key = moveKey .. "/" .. tostring(view.lives) .. (continued and "/c" or "") + + view.retry = (tried[moveKey] ~= nil) + if (view.frame >= view.window[1] + 3) and (view.frame <= view.window[2]) and not answered[key] then + answered[key] = true + tried[moveKey] = true + if missedMove(view) then + return + end + local cx, cy = (view.box[1] + view.box[3]) / 2, (view.box[2] + view.box[4]) / 2 + + if wrongMove(view) then + cx, cy = view.box[3] + 60, view.box[4] + 60 + end + algPointer(0, cx, cy) + tap(SWITCH_BUTTON3, 0) + if shotAt == nil then + shotAt = frames + end + end + end +end diff --git a/testScripts/ports/algOriginal.singe b/testScripts/ports/algOriginal.singe new file mode 100644 index 000000000..8c946976d --- /dev/null +++ b/testScripts/ports/algOriginal.singe @@ -0,0 +1,515 @@ +-- An American Laser Games title as it ships, driven by algDrive.singe and traced. PORT_GAME +-- names the title's folder under the library and PORT_SCRIPT its script under it (the Hypseus +-- layout: singe/<Title>/<Title>.singe); run with -v the title's framefile. +local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "") +local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest/hypseus") +local game = os.getenv("PORT_GAME") or "maddog-hd" +local script = library .. "/" .. game .. "/" .. (os.getenv("PORT_SCRIPT") or ("singe/" .. game .. "/" .. game .. ".singe")) + +dofile(here .. "algDrive.singe") + +SINGE_LEGACY_SPRITE_ARGS = true +local realTime = os.time +os.time = function(...) return 12345 end +local realScriptPath = singeGetScriptPath +singeGetScriptPath = function() return script end +local realSkip = discSkipToFrame +discSkipToFrame = function(frame) + algTraceSeek(frame) + return realSkip(frame) +end +local realSearch = discSearch +discSearch = function(frame) + algTraceSeek(frame) + return realSearch(frame) +end +-- One mouse: a single gun joins by START, as the game does with one mouse. +local realMice = mouseHowMany +mouseHowMany = function() return 1 end +dofile(script) +singeGetScriptPath = realScriptPath + +local realUpdate = onOverlayUpdate + +-- PORT_HOOK=1 names the line the script is on every ten million instructions: where a run +-- that never ends is spinning. +if os.getenv("PORT_HOOK") == "1" then + debug.sethook(function() + local where = debug.getinfo(2, "Sl") + + debugPrint("HOOK " .. tostring(where and where.short_src) .. ":" .. tostring(where and where.currentline)) + end, "", 10000000) +end + + +function algPress(switch, device, down) + if down then + onInputPressed(switch, device) + else + onInputReleased(switch, device) + end +end + + +-- A key released, as the typing edition reads it (its keysym, in MODE_FULL). +function algKey(keysym) + onInputReleased(keysym) +end + +-- The pointer as the game's onMouseMoved computes it: a raw position that lands on the point +-- asked for, in the game's own coordinates. +function algPointer(device, x, y) + local rx = (x + (ratioxOffset or 0)) / ((ratiox or 1) * (gunscale or 1)) + local ry = (y + (ratioyOffset or 0)) / ((ratioy or 1) * (gunscale or 1)) + + onMouseMoved(math.floor(rx), math.floor(ry), 0, 0, device) +end + + +-- The hitmap's coordinates scaled as the game scales them, where it does (the multiplayer +-- editions); the one-player editions aim in the hitmap's own. +local function hmx(v) + if ResHMx then + return ResHMx(v) + end + return v +end + + +local function hmy(v) + if ResHMy then + return ResHMy(v) + end + return v +end + + +-- The hitboxes of a frame in the current move's hitmap range, in pointer coordinates. +local function boxesAt(rel) + local boxes = {} + + for k = (move[currentMove][hitmapStart or hitboxStart] or 1), (hitmapTotal or 0) do + local row = hitmap[k] + + if row and (row[hitmapFrame] == rel) and (row[hitmapCount] > 0) then + for j = row[hitmapIndex], row[hitmapIndex] + row[hitmapCount] - 1 do + local b = hitbox[j] + + -- (A box given back to front -- Platoon's hitbox lines are, often -- can never be + -- hit by the game's own test, so it is no aim.) + if b and (b[bbx1] <= b[bbx2]) and (b[bby1] <= b[bby2]) then + boxes[#boxes + 1] = { hmx(b[bbx1]), hmy(b[bby1]), hmx(b[bbx2]), hmy(b[bby2]) } + end + end + break + end + end + + return boxes +end + + +local function firstBox() + -- The first hitbox of this frame, aimed at a point that also lies inside a box of the next + -- frame when the two overlap: the press the drive makes now is judged on this frame or the + -- next, as the clock falls. Without an overlap (a fast target) the box of this frame stands. + if (move == nil) or (move[currentMove] == nil) or (hitmap == nil) then + return nil + end + local rel = currentFrame - (thisOffset or 0) + local now = boxesAt(rel) + local b = now[1] + + if b == nil then + return nil + end + for _, n in ipairs(boxesAt(rel + 1)) do + local x1, y1, x2, y2 = math.max(b[1], n[1]), math.max(b[2], n[2]), math.min(b[3], n[3]), math.min(b[4], n[4]) + + if (x1 <= x2) and (y1 <= y2) then + return { x1, y1, x2, y2 } + end + end + + return b +end + + +local function view() + local window = nil + + -- A level of Mad Dog's (101 to 121) or of Mad Dog II's (its trails, and levelStart); Mad Dog II's + -- move rows have no shoot window of their own, so the move's frames stand in. + local inLevel = (currentLevel ~= nil) and (((frameShootStart ~= nil) and (currentLevel >= 101) and (currentLevel <= 121)) or ((frameShootStart == nil) and (((currentLevel >= 1) and (currentLevel <= 30)) or (currentLevel == levelStart)))) + + if ((practiceHit ~= nil) or (fRndPopOrder ~= nil)) and (currentLevel == levelPractice) then + inLevel = false + end + if (levelSigns ~= nil) and (currentLevel == levelSigns) then + inLevel = false + end + if (levelTieIntro ~= nil) and ((currentLevel == levelTie) or (currentLevel == levelTieIntro)) then + inLevel = false + end + + -- Space Pirates's puzzles and crystals are targets, not windows: the band and Ursula's colour + -- at the doors, Fellina's skull and arrow, the sockets of the crystal build. + if (levelCrystalBuild ~= nil) and ((currentLevel == levelCrystalBuild) or ((currentLevel == levelDoor2) and (lvlState == lvlPlayClip4)) or ((currentLevel == levelDoor3) and (lvlState == lvlPlayClip3)) or ((currentLevel == levelFellina1) and ((lvlState == lvlPlayPuzzle1) or (lvlState == lvlPlayPuzzle2)))) then + inLevel = false + end + + -- Who Shot Johnny Rock's store, clue items, and safe are targets, not windows. + if (GetMapArray01 ~= nil) and ((currentLevel == levelStore) or (currentLevel == levelMansion2) or (currentLevel == levelMansion4)) then + inLevel = false + end + + -- Space Pirates's Reaper wants exactly as many hits as he laughed: once the drive has them, + -- the moves left are let pass. + if (levelReaper2 ~= nil) and (currentLevel == levelReaper2) and (currentMove > 1) and (iReaperHits >= iReaperLaughs) then + inLevel = false + end + + -- The typing edition of Mad Dog II plays on the keyboard: the key the game waits for now (the + -- next letter of the word, a sprinkled letter, the return key at a menu), and no gun at all. + local typing = nil + + if validKey ~= nil then + inLevel = false + local function nextOf(word) + if word and (iKeyIndex or 1) <= string.len(word) then + return string.byte(word, iKeyIndex or 1) + end + return nil + end + -- (The game starts with the key slot at zero, not at none, and never clears it.) + local waiting = (iKey == SWITCH_NONE) or (iKey == 0) + + if not waiting then + typing = nil + elseif (currentLevel == levelTitle) and (lvlState == lvlRunning) then + typing = SWITCH_RETURN + elseif (currentLevel == levelMenu) and (lvlState == lvlRunning) then + typing = SWITCH_RETURN + elseif (currentLevel == levelHighScore) and (lvlState == lvlRunning) then + -- (Once, before the name's frames run out; the return key seeks to the entry's end.) + if discGetFrame() < offsetMenus + 3275 then + typing = SWITCH_RETURN + end + elseif bShowSprinkles and sprinkles then + for k = 1, (iSprinkleMax or 0) do + if sprinkles[k] and (sprinkles[k][4] == true) then + typing = string.byte(sprinkles[k][3]) + break + end + end + elseif (currentLevel == levelItem1) and (lvlState == lvlPlayClip3) then + typing = nextOf(sMagicWord) + elseif move and move[currentMove] and ((lvlState == lvlPauseAction) or ((lvlState == lvlRunning) and (discGetFrame() >= move[currentMove][moveFrameStart]) and (discGetFrame() < move[currentMove][moveFrameEnd]))) then + typing = nextOf(move[currentMove][(currentLevel == levelItem2) and moveType or wordval]) + end + end + + -- Platoon's patrols open with a clip the first press of the trigger jumps past, whatever the + -- state: no window until that press is in, and a target anywhere to make it. + local skipping = (levelCharlie ~= nil) and bOnce and (currentLevel ~= nil) and (currentLevel >= 1) and (currentLevel <= 6) and (lvlState == lvlRunning) + + if skipping then + inLevel = false + end + + -- A good guy's move (the Crime Patrol family marks the row) is no window: shooting him is a + -- death, and the drive would take it at every level that has one. + if inLevel and move and move[currentMove] and (rndMoveType ~= nil) and (GOODGUY ~= nil) and (move[currentMove][rndMoveType] == GOODGUY) then + inLevel = false + end + if inLevel and move and move[currentMove] then + window = { move[currentMove][frameShootStart or moveFrameStart], move[currentMove][frameShootEnd or moveFrameEnd] } + end + local quadrants = nil + + if (currentLevel == levelTownSelect) and stage then + quadrants = {} + local w, h = resX or 360, resY or 240 + + -- In the order the game itself takes when the select times out: the saloon before the + -- sheriff, whose level needs the key from it. + if not stage[STAGE_CORRAL] then quadrants[#quadrants + 1] = { w * 0.25, h * 0.25 } end + if not stage[STAGE_SALOON] then quadrants[#quadrants + 1] = { w * 0.75, h * 0.25 } end + if not stage[STAGE_SHERIFF] then quadrants[#quadrants + 1] = { w * 0.25, h * 0.75 } end + if not stage[STAGE_BANK] then quadrants[#quadrants + 1] = { w * 0.75, h * 0.75 } end + end + + -- The signpost's sign and the mine's lantern and item, as a list of points to shoot in + -- order (the middle line of each, scaled as the game scales them). + local targets = nil + local targetStep = nil + local function lineMiddle(rows, count) + local row = rows and rows[math.floor((count + 1) / 2)] + + if row then + return { hmx((row[lineStartX] + row[lineEndX]) / 2), hmy(row[lineY]) } + end + return nil + end + + if skipping then + targets = { { 180, 120 } } + targetStep = lvlRunning + elseif (currentLevel == levelSignSelect) and sign then + targets = {} + if stage[STAGE_FUSE] == false then + targets[1] = lineMiddle(sign[SIGN_PROSPECTOR], 48) + elseif stage[STAGE_MINE] == false then + targets[1] = lineMiddle(sign[SIGN_MINE], 28) + else + targets[1] = lineMiddle(sign[SIGN_HIDEOUT], 58) + end + elseif (currentLevel == levelMine) and mine then + targets = {} + local counts = { [MINE_SIGN] = 21, [MINE_LANTERN] = 20, [MINE_PAN] = 12, [MINE_PITCHFORK] = 23, [MINE_SKULL] = 25 } + + if not bLanternHit then targets[#targets + 1] = lineMiddle(mine[MINE_LANTERN], counts[MINE_LANTERN]) end + if not bItemHit then targets[#targets + 1] = lineMiddle(mine[iWhatToHit], counts[iWhatToHit]) end + elseif (currentLevel == levelMaze) and maze and maze[curMazePos] and (maze[curMazePos][mazeType] > 0) and sign then + -- A maze sign: the sign for the way to go. + local frame, dir = maze[curMazePos][signType], maze[curMazePos][signDirection] + local counts = { [32] = { 51, 49 }, [175] = { 59, 17 }, [340] = { 67, 90 }, [500] = { 24, 42 }, [630] = { 11, 30 }, [446] = { 59, 64 } } + local pair = counts[frame] + + if pair and sign[dir] then + targets = { lineMiddle(sign[dir], pair[dir]) } + end + elseif (levelItem1 ~= nil) and (ResHMx ~= nil) and (currentLevel == levelItem1) and (lvlState == lvlPlayClip3) then + -- Mad Dog II's first item level: the item the guide asked for, from the game's own + -- rectangles. + local boxes = { [ITEM_LANTERN] = { 57, 66, 117, 129 }, [ITEM_AMMOBOX] = { 120, 148, 111, 132 }, [ITEM_MOONSHINE] = { 260, 272, 60, 72 } } + local b = boxes[iThisItem] + + if b then + targets = { { hmx((b[1] + b[2]) / 2), hmy((b[3] + b[4]) / 2) } } + targetStep = lvlPlayClip3 + end + elseif (levelProspector1 ~= nil) and (ResHMx ~= nil) and (currentLevel == levelProspector1) and (lvlState == lvlPlayClip4) then + -- Mad Dog II's prospector: the skull he chose, from the game's own rectangles. + local boxes = { { 305, 315, 59, 76 }, { 68, 75, 73, 82 }, { 147, 154, 58, 67 }, { 119, 127, 101, 115 } } + local b = boxes[iSkull] or boxes[4] + + targets = { { hmx((b[1] + b[2]) / 2), hmy((b[3] + b[4]) / 2) } } + targetStep = lvlPlayClip4 + elseif (panelHit ~= nil) and (currentLevel == levelMenu) and (lvlState == lvlRunning) and area then + -- Crime Patrol's mission menus: the first panel whose stage is open, by the middle line + -- of its area (the count of lines is the game's own). + local MENUS = {} + + if MENU_SIERRA ~= nil then + MENUS[MENU_SIERRA] = { { PANEL_PRACTICE, STAGE_PRACTICE, 99 }, { PANEL_BAR, STAGE_BAR, 99 }, { PANEL_CARCHASE, STAGE_CARCHASE, 99 }, { PANEL_HIDEOUT, STAGE_HIDEOUT, 99 } } + MENUS[MENU_CHICAGO] = { { PANEL_HELI, STAGE_HELI, 111 }, { PANEL_COURTROOM, STAGE_COURTROOM, 111 }, { PANEL_BUSCHASE, STAGE_BUSCHASE, 111 } } + MENUS[MENU_BORDER] = { { PANEL_SHIPYARD, STAGE_SHIPYARD, 111 }, { PANEL_BOATHOUSE, STAGE_BOATHOUSE, 111 }, { PANEL_BEACH, STAGE_BEACH, 111 } } + MENUS[MENU_SA] = { { PANEL_BASE, STAGE_BASE, 111 }, { PANEL_NIGHTVISION, STAGE_NIGHTVISION, 111 }, { PANEL_TOWN, STAGE_TOWN, 111 } } + end + if MENU_ROOKIE ~= nil then + MENUS = { [MENU_ROOKIE] = { { PANEL_PRACTICE, STAGE_PRACTICE, 98 }, { PANEL_STORE, STAGE_STORE, 99 }, { PANEL_GANG, STAGE_GANG, 99 }, { PANEL_WAREHOUSE, STAGE_WAREHOUSE, 97 } }, + [MENU_UNDERCOVER] = { { PANEL_AIRPORT, STAGE_AIRPORT, 104 }, { PANEL_GARAGE, STAGE_GARAGE, 104 }, { PANEL_STRIPCLUB, STAGE_STRIPCLUB, 104 } }, + [MENU_SWAT] = { { PANEL_DRUGS1, STAGE_DRUGS1, 111 }, { PANEL_DRUGS2, STAGE_DRUGS2, 111 }, { PANEL_BANK, STAGE_ROBBERY, 103 } }, + [MENU_DELTA] = { { PANEL_HANGAR, STAGE_HANGAR, 108 }, { PANEL_AMBUSH, STAGE_AMBUSH, 103 }, { PANEL_AIRTERRORISTS, STAGE_AIRTERRORISTS, 109 } } } + end + + for _, entry in ipairs(MENUS[iCurrentMenu] or {}) do + if not panel[entry[2]] then + targets = { lineMiddle(area[entry[1]], entry[3]) } + break + end + end + elseif (GetArrayPanels ~= nil) and (currentLevel == levelMenu) and (lvlState == lvlRunning) and area then + -- The Last Bounty Hunter's wanted posters: the first outlaw still at large, by the middle of + -- his panel's rectangle. + for _, stage in ipairs({ STAGE_HARRY, STAGE_DAN, STAGE_LOCO, STAGE_CACTUS }) do + local r = (panel[stage] == ATLARGE) and area[stage] and area[stage][1] + + if r then + targets = { { hmx((r[lineStartX] + r[lineEndX]) / 2), hmy((r[lineStartY] + r[lineEndY]) / 2) } } + break + end + end + elseif (levelSigns ~= nil) and (currentLevel == levelSigns) and (lvlState == lvlEnd) then + -- The Last Bounty Hunter's signs: the practice range, in the game's own pointer coordinates. + targets = { { 175, 101 } } + targetStep = lvlEnd + elseif (iLastMove ~= nil) and (levelFinal ~= nil) and (currentLevel == levelFinal) and (lvlState == lvlPlayClip2) then + -- Drug Wars's last stand: the tank, the blanket, or the gas cans, in the game's own + -- pointer coordinates. + local STAND = { { 262, 329, 133, 172 }, { 101, 111, 160, 188 }, { 209, 243, 201, 215 } } + local b = STAND[iLastMove] or STAND[3] + + targets = { { (b[1] + b[2]) / 2, (b[3] + b[4]) / 2 } } + targetStep = lvlPlayClip2 + elseif (practiceHit ~= nil) and (currentLevel == levelPractice) and (lvlState == lvlRunning) then + -- Crime Patrol's practice range: the first standing target of the still, in the game's + -- own pointer coordinates, or the cutout of the move (the middle line of its outline). + if currentMove == 0 then + local STILL = { { "bTopRight", 261, 300, 9, 60 }, { "bTopLeft", 78, 119, 70, 120 }, { "bMiddle", 205, 243, 70, 123 }, { "bBottomLeft", 120, 158, 124, 181 }, { "bBottomRight", 268, 297, 132, 183 } } + + for _, t in ipairs(STILL) do + if _G[t[1]] then + targets = { { (t[2] + t[3]) / 2, (t[4] + t[5]) / 2 } } + break + end + end + elseif (currentMove >= 1) and move and move[currentMove] and target then + local kind = move[currentMove][rndMoveType] + local counts = { [BADGUY01] = 98, [CIV01] = 82, [BADGUY02] = 97, [CIV02] = 107, [BADGUY03] = 79, [CIV03] = 148 } + + if kind and counts[kind] and target[kind] then + targets = { lineMiddle(target[kind], counts[kind]) } + end + end + elseif (levelFirePractice ~= nil) and (currentLevel == levelMenu) and (lvlState == lvlRunning) and panel then + -- Space Pirates's planet menu: the first panel not yet done, in the order the game takes + -- when the clip runs out, by the middle of its quadrant (pointer coordinates scaled as the + -- game scales them). + local PANELS = { { STAGE_FELLINA, 807, 1280, 565, 916 }, { STAGE_REAPER, 245, 720, 565, 916 }, { STAGE_SCRAPYARD, 807, 1280, 100, 450 }, { STAGE_MOUNTAIN, 245, 720, 100, 450 } } + + for _, p in ipairs(PANELS) do + if panel[p[1]] == false then + targets = { { UnX * (p[2] + p[3]) / 2, UnY * (p[4] + p[5]) / 2 } } + break + end + end + elseif (levelDoor2 ~= nil) and (currentLevel == levelDoor2) and (lvlState == lvlPlayClip4) and band then + -- Space Pirates's band of colour: the right one, within its frames, by the middle of its + -- line. + local row = band[iBand] and band[iBand][1] + + if row and (discGetFrame() >= bandFrameStart + 3) and (discGetFrame() < bandFrameEnd) then + targets = { { hmx((row[lineStartX] + row[lineEndX]) / 2), hmy((row[lineStartY] + row[lineEndY]) / 2) } } + targetStep = lvlPlayClip4 + end + elseif (levelDoor3 ~= nil) and (currentLevel == levelDoor3) and (lvlState == lvlPlayClip3) and (ursie or (ursulaHit ~= nil)) then + -- Space Pirates's commander: Ursula shot within her colour's frames. + local WINDOWS = { [COLOR_RED] = { 2243, 2306 }, [COLOR_GREEN] = { 2312, 2371 }, [COLOR_BLUE] = { 2377, 2439 } } + local w = WINDOWS[iUrsulaColor] + local rel = discGetFrame() - thisOffset + + if w and (rel >= w[1] + 3) and (rel <= w[2] - 3) then + -- (The one-player edition builds her rectangles inside its hit test, so before the first + -- shot the game's own numbers stand in.) + local box = (ursie and ursie[1]) or { 152, 137, 210, 238 } + + targets = { { hmx((box[1] + box[3]) / 2), hmy((box[2] + box[4]) / 2) } } + targetStep = lvlPlayClip3 + end + elseif (levelFellina1 ~= nil) and (currentLevel == levelFellina1) and ((lvlState == lvlPlayPuzzle1) or (lvlState == lvlPlayPuzzle2)) and thingie then + -- Space Pirates's Fellina puzzles: the skull she chose, then the arrow, by the middle of + -- the game's own line. + local which = (lvlState == lvlPlayPuzzle1) and iFellinaSkull or iFellinaArrow + local row = thingie[which] and thingie[which][1] + + if row and ((lvlState == lvlPlayPuzzle2) or (discGetFrame() >= iSkullStart + 3)) then + targets = { { hmx((row[lineStartX] + row[lineEndX]) / 2), hmy((row[lineStartY] + row[lineEndY]) / 2) } } + targetStep = lvlState + end + elseif (levelCrystalBuild ~= nil) and (currentLevel == levelCrystalBuild) and (lvlState == lvlRunning) and hole and socket then + -- Space Pirates's crystal build: the next socket in the order shown. + local ORDERS = { [ORDER_RGB] = { SOCKET_RED, SOCKET_GREEN, SOCKET_BLUE }, [ORDER_GBR] = { SOCKET_GREEN, SOCKET_BLUE, SOCKET_RED }, [ORDER_BRG] = { SOCKET_BLUE, SOCKET_RED, SOCKET_GREEN } } + + for _, which in ipairs(ORDERS[iCrystalOrder] or {}) do + if not socket[which] then + local row = hole[which] and hole[which][1] + + if row then + targets = { { hmx((row[lineStartX] + row[lineEndX]) / 2), hmy((row[lineStartY] + row[lineEndY]) / 2) } } + end + break + end + end + elseif (GetMapArray01 ~= nil) and (currentLevel == levelMenu) and (lvlState == lvlRunning) and stage then + -- Who Shot Johnny Rock's city map: the killer's place once revealed, the mansion once the + -- clues are all had, else the first place still open (the casino, the pool hall, the + -- warehouse, the garage), by the middle of its panel. + local which = nil + + if bKillerRevealed then + which = ({ [JILL] = PLACE_CASINO, [MUMPS] = PLACE_POOL, [MEASLES] = PLACE_WAREHOUSE, [POX] = PLACE_GARAGE })[iKiller] + elseif bGotAllClues then + which = PLACE_MANSION + elseif not stage[levelCasino] then + which = PLACE_CASINO + elseif not stage[levelPool] then + which = PLACE_POOL + elseif not (stage[levelWarehouse1] and stage[levelWarehouse2] and stage[levelWarehouse3]) then + which = PLACE_WAREHOUSE + elseif not stage[levelGarage] then + which = PLACE_GARAGE + end + if which then + GetMapArray01() + local row = place[which] and place[which][1] + + if row then + targets = { { hmx((row[lineStartX] + row[lineEndX]) / 2), hmy((row[lineStartY] + row[lineEndY]) / 2) } } + end + end + elseif (GetMapArray01 ~= nil) and (currentLevel == levelMansion2) and (lvlState == lvlRunning) and clue and thisItem then + -- Who Shot Johnny Rock's clue items: the item of the clue asked for, in the game's second + -- scale. + GetArrayItems() + local row = item[clue[thisItem]] and item[clue[thisItem]][1] + + if row then + targets = { { ResHMx2((row[lineStartX] + row[lineEndX]) / 2), ResHMy2((row[lineStartY] + row[lineEndY]) / 2) } } + end + elseif (GetMapArray01 ~= nil) and (currentLevel == levelMansion4) and (lvlState == lvlRunning) and combination and iSafeNumber then + -- Who Shot Johnny Rock's safe: the dial shot while it shows the combination's next digit + -- (on this frame and the next, since the press is judged on the next). + local digit = tonumber(string.sub(combination, iSafeNumber, iSafeNumber)) + local rel = discGetFrame() - thisOffset + + if (getSafeNumber(rel) == digit) and (getSafeNumber(rel + 1) == digit) then + targets = { { hmx((151 + 207) / 2), hmy((64 + 88) / 2) } } + end + elseif (GUIDE_BEAVER ~= nil) and (ResHMx ~= nil) and (currentLevel == levelMenu) and panel then + -- Mad Dog II's guide menu: the first guide whose trail is not done, by its panel. + targets = {} + local x = (panel[GUIDE_BEAVER] == false) and 90 or ((panel[GUIDE_BONNIE] == false) and 186 or ((panel[GUIDE_PROF] == false) and 282 or nil)) + + if x then + targets[1] = { hmx(x), hmy(121) } + end + end + + -- At the attract loop: Mad Dog's state machine names it currentState, Mad Dog II's gameflow. + local playing = ((currentState or gameflow) == statePlaying) + local intro = playing and (currentLevel == (lvlIntro or levelIntro)) + + -- The service screen: the option under the cursor and every dip, for the trace; a screen + -- the stick drives rather than the pointer (Hologram Time Traveler). + local dips = {} + + for key, value in pairs(_G) do + if (type(key) == "string") and (key:sub(1, 4) == "dip_") and (type(value) ~= "function") then + dips[#dips + 1] = key:sub(5) .. "=" .. tostring(value) + end + end + table.sort(dips) + + return { screen = currentLevel, step = lvlState, move = currentMove, frame = discGetFrame(), frames = frames, score = iScore, lives = iLives or iMoney, ammo = iBullets, active = p1Active, intro = intro, + service = (levelService ~= nil) and (currentLevel == levelService), serviceKeys = (TURNRIGHTSHOOT ~= nil), opt = optSel, dips = table.concat(dips, ","), width = overlayGetWidth(), height = overlayGetHeight(), + board = (levelHighScore ~= nil) and (currentLevel == levelHighScore), + window = window, box = window and firstBox() or nil, quadrants = quadrants, targets = targets, targetStep = targetStep, continuing = (currentLevel == levelContinue), noSelect = (mouseIndexSelect == nil), typing = typing, typed = (validKey ~= nil), offscreen = { -5, -5 }, free = bAmmo, paused = bPause, + -- The one-player editions (no player select, lives rather than money) spend a shot + -- before the level tests for ammo, so their last shot is never judged: a spare to keep. + spare = ((mouseIndexSelect == nil) and (iMoney == nil) and (iBullets ~= nil) and ((panelHit == nil) or (resX ~= nil)) and (levelCharlie == nil)) and 1 or 0, + -- The library's one-player Crime Patrol family judges a shot before the release takes its + -- bullet (its HD twins take it on the press, as the others do), and reloads by its second + -- button (immediately, as its config ships); so does Platoon. + reloadSwitch = (((panelHit ~= nil) and (mouseIndexSelect == nil)) or (levelCharlie ~= nil)) and SWITCH_BUTTON1 or SWITCH_BUTTON3 } +end + + +onOverlayUpdate = function(...) + local result = realUpdate(...) + + algDrive(view()) + + return result +end diff --git a/testScripts/ports/algPort.singe b/testScripts/ports/algPort.singe new file mode 100644 index 000000000..49b5f8e36 --- /dev/null +++ b/testScripts/ports/algPort.singe @@ -0,0 +1,503 @@ +-- The Forge port of an American Laser Games title, driven and traced as the original is. +local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "") +local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest/hypseus") +local game = os.getenv("PORT_GAME") or "maddog-hd" +local name = (os.getenv("PORT_SCRIPT") or ("singe/" .. game .. "/" .. game .. ".singe")):gsub("%.singe$", "") +local stem = name:match("([^/\\]+)$") + +dofile("Forge/AuthorCompile.singe") +dofile(here .. "algDrive.singe") + +-- The game seeds its random draws from os.time at load; the original's driver pins it, and so +-- does this, to the same number. +os.time = function(...) return 12345 end + +local realSkip = discSkipToFrame +discSkipToFrame = function(frame) + algTraceSeek(frame) + return realSkip(frame) +end +local realSearch = discSearch +discSearch = function(frame) + algTraceSeek(frame) + return realSearch(frame) +end +dofile(authorBuild(library .. "/" .. game .. "/" .. name .. ".forge", singeGetDataPath() .. stem .. ".port.singe")) + +local realUpdate = onOverlayUpdate + + +function algPress(switch, device, down) + if down then + authorSwitchDown(switch, device) + else + authorSwitchUp(switch, device) + end +end + + +-- A key released, as the typing edition reads it: the runtime hands a keysym over as a switch. +function algKey(keysym) + authorSwitchUp(keysym) +end + +function algPointer(device, x, y) + local q = authorEntity("game").q + local rx = (x + (q.ratioxOffset or 0)) / ((q.ratiox or 1) * (q.gunscale or 1)) + local ry = (y + (q.ratioyOffset or 0)) / ((q.ratioy or 1) * (q.gunscale or 1)) + + onMouseMoved(math.floor(rx), math.floor(ry), 0, 0, device) +end + + +-- The hitmap's coordinates scaled as the game scales them, where it does (the multiplayer +-- editions); the one-player editions aim in the hitmap's own. +local function hmx(v) + local q = authorEntity("game").q + + if q.ResHMx then + return q.ResHMx(v) + end + return v +end + + +local function hmy(v) + local q = authorEntity("game").q + + if q.ResHMy then + return q.ResHMy(v) + end + return v +end + + +-- The hitboxes of a frame in the current move's hitmap range, in pointer coordinates. +local function boxesAt(q, rel) + local boxes = {} + + for k = (q.move[q.currentMove][q.hitmapStart or q.hitboxStart] or 1), (q.hitmapTotal or 0) do + local row = q.hitmap[k] + + if row and (row[q.hitmapFrame] == rel) and (row[q.hitmapCount] > 0) then + for j = row[q.hitmapIndex], row[q.hitmapIndex] + row[q.hitmapCount] - 1 do + local b = q.hitbox[j] + + -- (A box given back to front -- Platoon's hitbox lines are, often -- can never be + -- hit by the game's own test, so it is no aim.) + if b and (b[q.bbx1] <= b[q.bbx2]) and (b[q.bby1] <= b[q.bby2]) then + boxes[#boxes + 1] = { hmx(b[q.bbx1]), hmy(b[q.bby1]), hmx(b[q.bbx2]), hmy(b[q.bby2]) } + end + end + break + end + end + + return boxes +end + + +local function firstBox(q) + -- The first hitbox of this frame, aimed at a point that also lies inside a box of the next + -- frame when the two overlap: the press the drive makes now is judged on this frame or the + -- next, as the clock falls. Without an overlap (a fast target) the box of this frame stands. + if (q.move == nil) or (q.move[q.currentMove] == nil) or (q.hitmap == nil) then + return nil + end + local rel = q.currentFrame - (q.thisOffset or 0) + local now = boxesAt(q, rel) + local b = now[1] + + if b == nil then + return nil + end + for _, n in ipairs(boxesAt(q, rel + 1)) do + local x1, y1, x2, y2 = math.max(b[1], n[1]), math.max(b[2], n[2]), math.min(b[3], n[3]), math.min(b[4], n[4]) + + if (x1 <= x2) and (y1 <= y2) then + return { x1, y1, x2, y2 } + end + end + + return b +end + + +local function view() + local q = authorEntity("game").q + local window = nil + + -- A level of Mad Dog's (101 to 121) or of Mad Dog II's (its trails, and levelStart); Mad Dog II's + -- move rows have no shoot window of their own, so the move's frames stand in. + local inLevel = (q.currentLevel ~= nil) and (((q.frameShootStart ~= nil) and (q.currentLevel >= 101) and (q.currentLevel <= 121)) or ((q.frameShootStart == nil) and (((q.currentLevel >= 1) and (q.currentLevel <= 30)) or (q.currentLevel == q.levelStart)))) + + if ((q.practiceHit ~= nil) or (q.fRndPopOrder ~= nil)) and (q.currentLevel == q.levelPractice) then + inLevel = false + end + if (q.levelSigns ~= nil) and (q.currentLevel == q.levelSigns) then + inLevel = false + end + if (q.levelTieIntro ~= nil) and ((q.currentLevel == q.levelTie) or (q.currentLevel == q.levelTieIntro)) then + inLevel = false + end + + -- Space Pirates's puzzles and crystals are targets, not windows: the band and Ursula's colour + -- at the doors, Fellina's skull and arrow, the sockets of the crystal build. + if (q.levelCrystalBuild ~= nil) and ((q.currentLevel == q.levelCrystalBuild) or ((q.currentLevel == q.levelDoor2) and (q.lvlState == q.lvlPlayClip4)) or ((q.currentLevel == q.levelDoor3) and (q.lvlState == q.lvlPlayClip3)) or ((q.currentLevel == q.levelFellina1) and ((q.lvlState == q.lvlPlayPuzzle1) or (q.lvlState == q.lvlPlayPuzzle2)))) then + inLevel = false + end + + -- Who Shot Johnny Rock's store, clue items, and safe are targets, not windows. + if (q.GetMapArray01 ~= nil) and ((q.currentLevel == q.levelStore) or (q.currentLevel == q.levelMansion2) or (q.currentLevel == q.levelMansion4)) then + inLevel = false + end + + -- Space Pirates's Reaper wants exactly as many hits as he laughed: once the drive has them, + -- the moves left are let pass. + if (q.levelReaper2 ~= nil) and (q.currentLevel == q.levelReaper2) and (q.currentMove > 1) and (q.iReaperHits >= q.iReaperLaughs) then + inLevel = false + end + + -- The typing edition of Mad Dog II plays on the keyboard: the key the game waits for now (the + -- next letter of the word, a sprinkled letter, the return key at a menu), and no gun at all. + local typing = nil + + if q.validKey ~= nil then + inLevel = false + local function nextOf(word) + if word and (q.iKeyIndex or 1) <= string.len(word) then + return string.byte(word, q.iKeyIndex or 1) + end + return nil + end + -- (The game starts with the key slot at zero, not at none, and never clears it.) + local waiting = (q.iKey == q.SWITCH_NONE) or (q.iKey == 0) + + if not waiting then + typing = nil + elseif (q.currentLevel == q.levelTitle) and (q.lvlState == q.lvlRunning) then + typing = q.SWITCH_RETURN + elseif (q.currentLevel == q.levelMenu) and (q.lvlState == q.lvlRunning) then + typing = q.SWITCH_RETURN + elseif (q.currentLevel == q.levelHighScore) and (q.lvlState == q.lvlRunning) then + -- (Once, before the name's frames run out; the return key seeks to the entry's end.) + if discGetFrame() < q.offsetMenus + 3275 then + typing = q.SWITCH_RETURN + end + elseif q.bShowSprinkles and q.sprinkles then + for k = 1, (q.iSprinkleMax or 0) do + if q.sprinkles[k] and (q.sprinkles[k][4] == true) then + typing = string.byte(q.sprinkles[k][3]) + break + end + end + elseif (q.currentLevel == q.levelItem1) and (q.lvlState == q.lvlPlayClip3) then + typing = nextOf(q.sMagicWord) + elseif q.move and q.move[q.currentMove] and ((q.lvlState == q.lvlPauseAction) or ((q.lvlState == q.lvlRunning) and (discGetFrame() >= q.move[q.currentMove][q.moveFrameStart]) and (discGetFrame() < q.move[q.currentMove][q.moveFrameEnd]))) then + typing = nextOf(q.move[q.currentMove][(q.currentLevel == q.levelItem2) and q.moveType or q.wordval]) + end + end + + -- Platoon's patrols open with a clip the first press of the trigger jumps past, whatever the + -- state: no window until that press is in, and a target anywhere to make it. + local skipping = (q.levelCharlie ~= nil) and q.bOnce and (q.currentLevel ~= nil) and (q.currentLevel >= 1) and (q.currentLevel <= 6) and (q.lvlState == q.lvlRunning) + + if skipping then + inLevel = false + end + + -- A good guy's move (the Crime Patrol family marks the row) is no window: shooting him is a + -- death, and the drive would take it at every level that has one. + if inLevel and q.move and q.move[q.currentMove] and (q.rndMoveType ~= nil) and (q.GOODGUY ~= nil) and (q.move[q.currentMove][q.rndMoveType] == q.GOODGUY) then + inLevel = false + end + if inLevel and q.move and q.move[q.currentMove] then + window = { q.move[q.currentMove][q.frameShootStart or q.moveFrameStart], q.move[q.currentMove][q.frameShootEnd or q.moveFrameEnd] } + end + local quadrants = nil + + if (q.currentLevel == q.levelTownSelect) and q.stage then + quadrants = {} + local w, h = q.resX or 360, q.resY or 240 + + -- In the order the game itself takes when the select times out: the saloon before the + -- sheriff, whose level needs the key from it. + if not q.stage[q.STAGE_CORRAL] then quadrants[#quadrants + 1] = { w * 0.25, h * 0.25 } end + if not q.stage[q.STAGE_SALOON] then quadrants[#quadrants + 1] = { w * 0.75, h * 0.25 } end + if not q.stage[q.STAGE_SHERIFF] then quadrants[#quadrants + 1] = { w * 0.25, h * 0.75 } end + if not q.stage[q.STAGE_BANK] then quadrants[#quadrants + 1] = { w * 0.75, h * 0.75 } end + end + + -- The signpost's sign and the mine's lantern and item, as a list of points to shoot in + -- order (the middle line of each, scaled as the game scales them). + local targets = nil + local targetStep = nil + local function lineMiddle(rows, count) + local row = rows and rows[math.floor((count + 1) / 2)] + + if row then + return { hmx((row[q.lineStartX] + row[q.lineEndX]) / 2), hmy(row[q.lineY]) } + end + return nil + end + + if skipping then + targets = { { 180, 120 } } + targetStep = q.lvlRunning + elseif (q.currentLevel == q.levelSignSelect) and q.sign then + targets = {} + if q.stage[q.STAGE_FUSE] == false then + targets[1] = lineMiddle(q.sign[q.SIGN_PROSPECTOR], 48) + elseif q.stage[q.STAGE_MINE] == false then + targets[1] = lineMiddle(q.sign[q.SIGN_MINE], 28) + else + targets[1] = lineMiddle(q.sign[q.SIGN_HIDEOUT], 58) + end + elseif (q.currentLevel == q.levelMine) and q.mine then + targets = {} + local counts = { [q.MINE_SIGN] = 21, [q.MINE_LANTERN] = 20, [q.MINE_PAN] = 12, [q.MINE_PITCHFORK] = 23, [q.MINE_SKULL] = 25 } + + if not q.bLanternHit then targets[#targets + 1] = lineMiddle(q.mine[q.MINE_LANTERN], counts[q.MINE_LANTERN]) end + if not q.bItemHit then targets[#targets + 1] = lineMiddle(q.mine[q.iWhatToHit], counts[q.iWhatToHit]) end + elseif (q.currentLevel == q.levelMaze) and q.maze and q.maze[q.curMazePos] and (q.maze[q.curMazePos][q.mazeType] > 0) and q.sign then + -- A maze sign: the sign for the way to go. + local frame, dir = q.maze[q.curMazePos][q.signType], q.maze[q.curMazePos][q.signDirection] + local counts = { [32] = { 51, 49 }, [175] = { 59, 17 }, [340] = { 67, 90 }, [500] = { 24, 42 }, [630] = { 11, 30 }, [446] = { 59, 64 } } + local pair = counts[frame] + + if pair and q.sign[dir] then + targets = { lineMiddle(q.sign[dir], pair[dir]) } + end + elseif (q.levelItem1 ~= nil) and (q.ResHMx ~= nil) and (q.currentLevel == q.levelItem1) and (q.lvlState == q.lvlPlayClip3) then + -- Mad Dog II's first item level: the item the guide asked for, from the game's own + -- rectangles. + local boxes = { [q.ITEM_LANTERN] = { 57, 66, 117, 129 }, [q.ITEM_AMMOBOX] = { 120, 148, 111, 132 }, [q.ITEM_MOONSHINE] = { 260, 272, 60, 72 } } + local b = boxes[q.iThisItem] + + if b then + targets = { { hmx((b[1] + b[2]) / 2), hmy((b[3] + b[4]) / 2) } } + targetStep = q.lvlPlayClip3 + end + elseif (q.levelProspector1 ~= nil) and (q.ResHMx ~= nil) and (q.currentLevel == q.levelProspector1) and (q.lvlState == q.lvlPlayClip4) then + -- Mad Dog II's prospector: the skull he chose, from the game's own rectangles. + local boxes = { { 305, 315, 59, 76 }, { 68, 75, 73, 82 }, { 147, 154, 58, 67 }, { 119, 127, 101, 115 } } + local b = boxes[q.iSkull] or boxes[4] + + targets = { { hmx((b[1] + b[2]) / 2), hmy((b[3] + b[4]) / 2) } } + targetStep = q.lvlPlayClip4 + elseif (q.panelHit ~= nil) and (q.currentLevel == q.levelMenu) and (q.lvlState == q.lvlRunning) and q.area then + -- Crime Patrol's mission menus: the first panel whose stage is open, by the middle line + -- of its area (the count of lines is the game's own). + local MENUS = {} + + if q.MENU_SIERRA ~= nil then + MENUS[q.MENU_SIERRA] = { { q.PANEL_PRACTICE, q.STAGE_PRACTICE, 99 }, { q.PANEL_BAR, q.STAGE_BAR, 99 }, { q.PANEL_CARCHASE, q.STAGE_CARCHASE, 99 }, { q.PANEL_HIDEOUT, q.STAGE_HIDEOUT, 99 } } + MENUS[q.MENU_CHICAGO] = { { q.PANEL_HELI, q.STAGE_HELI, 111 }, { q.PANEL_COURTROOM, q.STAGE_COURTROOM, 111 }, { q.PANEL_BUSCHASE, q.STAGE_BUSCHASE, 111 } } + MENUS[q.MENU_BORDER] = { { q.PANEL_SHIPYARD, q.STAGE_SHIPYARD, 111 }, { q.PANEL_BOATHOUSE, q.STAGE_BOATHOUSE, 111 }, { q.PANEL_BEACH, q.STAGE_BEACH, 111 } } + MENUS[q.MENU_SA] = { { q.PANEL_BASE, q.STAGE_BASE, 111 }, { q.PANEL_NIGHTVISION, q.STAGE_NIGHTVISION, 111 }, { q.PANEL_TOWN, q.STAGE_TOWN, 111 } } + end + if q.MENU_ROOKIE ~= nil then + MENUS = { [q.MENU_ROOKIE] = { { q.PANEL_PRACTICE, q.STAGE_PRACTICE, 98 }, { q.PANEL_STORE, q.STAGE_STORE, 99 }, { q.PANEL_GANG, q.STAGE_GANG, 99 }, { q.PANEL_WAREHOUSE, q.STAGE_WAREHOUSE, 97 } }, + [q.MENU_UNDERCOVER] = { { q.PANEL_AIRPORT, q.STAGE_AIRPORT, 104 }, { q.PANEL_GARAGE, q.STAGE_GARAGE, 104 }, { q.PANEL_STRIPCLUB, q.STAGE_STRIPCLUB, 104 } }, + [q.MENU_SWAT] = { { q.PANEL_DRUGS1, q.STAGE_DRUGS1, 111 }, { q.PANEL_DRUGS2, q.STAGE_DRUGS2, 111 }, { q.PANEL_BANK, q.STAGE_ROBBERY, 103 } }, + [q.MENU_DELTA] = { { q.PANEL_HANGAR, q.STAGE_HANGAR, 108 }, { q.PANEL_AMBUSH, q.STAGE_AMBUSH, 103 }, { q.PANEL_AIRTERRORISTS, q.STAGE_AIRTERRORISTS, 109 } } } + end + + for _, entry in ipairs(MENUS[q.iCurrentMenu] or {}) do + if not q.panel[entry[2]] then + targets = { lineMiddle(q.area[entry[1]], entry[3]) } + break + end + end + elseif (q.GetArrayPanels ~= nil) and (q.currentLevel == q.levelMenu) and (q.lvlState == q.lvlRunning) and q.area then + -- The Last Bounty Hunter's wanted posters: the first outlaw still at large, by the middle of + -- his panel's rectangle. + for _, stage in ipairs({ q.STAGE_HARRY, q.STAGE_DAN, q.STAGE_LOCO, q.STAGE_CACTUS }) do + local r = (q.panel[stage] == q.ATLARGE) and q.area[stage] and q.area[stage][1] + + if r then + targets = { { hmx((r[q.lineStartX] + r[q.lineEndX]) / 2), hmy((r[q.lineStartY] + r[q.lineEndY]) / 2) } } + break + end + end + elseif (q.levelSigns ~= nil) and (q.currentLevel == q.levelSigns) and (q.lvlState == q.lvlEnd) then + -- The Last Bounty Hunter's signs: the practice range, in the game's own pointer coordinates. + targets = { { 175, 101 } } + targetStep = q.lvlEnd + elseif (q.iLastMove ~= nil) and (q.levelFinal ~= nil) and (q.currentLevel == q.levelFinal) and (q.lvlState == q.lvlPlayClip2) then + -- Drug Wars's last stand: the tank, the blanket, or the gas cans, in the game's own + -- pointer coordinates. + local STAND = { { 262, 329, 133, 172 }, { 101, 111, 160, 188 }, { 209, 243, 201, 215 } } + local b = STAND[q.iLastMove] or STAND[3] + + targets = { { (b[1] + b[2]) / 2, (b[3] + b[4]) / 2 } } + targetStep = q.lvlPlayClip2 + elseif (q.practiceHit ~= nil) and (q.currentLevel == q.levelPractice) and (q.lvlState == q.lvlRunning) then + -- Crime Patrol's practice range: the first standing target of the still, in the game's + -- own pointer coordinates, or the cutout of the move (the middle line of its outline). + if q.currentMove == 0 then + local STILL = { { "bTopRight", 261, 300, 9, 60 }, { "bTopLeft", 78, 119, 70, 120 }, { "bMiddle", 205, 243, 70, 123 }, { "bBottomLeft", 120, 158, 124, 181 }, { "bBottomRight", 268, 297, 132, 183 } } + + for _, t in ipairs(STILL) do + if q[t[1]] then + targets = { { (t[2] + t[3]) / 2, (t[4] + t[5]) / 2 } } + break + end + end + elseif (q.currentMove >= 1) and q.move and q.move[q.currentMove] and q.target then + local kind = q.move[q.currentMove][q.rndMoveType] + local counts = { [q.BADGUY01] = 98, [q.CIV01] = 82, [q.BADGUY02] = 97, [q.CIV02] = 107, [q.BADGUY03] = 79, [q.CIV03] = 148 } + + if kind and counts[kind] and q.target[kind] then + targets = { lineMiddle(q.target[kind], counts[kind]) } + end + end + elseif (q.levelFirePractice ~= nil) and (q.currentLevel == q.levelMenu) and (q.lvlState == q.lvlRunning) and q.panel then + -- Space Pirates's planet menu: the first panel not yet done, in the order the game takes + -- when the clip runs out, by the middle of its quadrant (pointer coordinates scaled as the + -- game scales them). + local PANELS = { { q.STAGE_FELLINA, 807, 1280, 565, 916 }, { q.STAGE_REAPER, 245, 720, 565, 916 }, { q.STAGE_SCRAPYARD, 807, 1280, 100, 450 }, { q.STAGE_MOUNTAIN, 245, 720, 100, 450 } } + + for _, p in ipairs(PANELS) do + if q.panel[p[1]] == false then + targets = { { q.UnX * (p[2] + p[3]) / 2, q.UnY * (p[4] + p[5]) / 2 } } + break + end + end + elseif (q.levelDoor2 ~= nil) and (q.currentLevel == q.levelDoor2) and (q.lvlState == q.lvlPlayClip4) and q.band then + -- Space Pirates's band of colour: the right one, within its frames, by the middle of its + -- line. + local row = q.band[q.iBand] and q.band[q.iBand][1] + + if row and (discGetFrame() >= q.bandFrameStart + 3) and (discGetFrame() < q.bandFrameEnd) then + targets = { { hmx((row[q.lineStartX] + row[q.lineEndX]) / 2), hmy((row[q.lineStartY] + row[q.lineEndY]) / 2) } } + targetStep = q.lvlPlayClip4 + end + elseif (q.levelDoor3 ~= nil) and (q.currentLevel == q.levelDoor3) and (q.lvlState == q.lvlPlayClip3) and (q.ursie or (q.ursulaHit ~= nil)) then + -- Space Pirates's commander: Ursula shot within her colour's frames. + local WINDOWS = { [q.COLOR_RED] = { 2243, 2306 }, [q.COLOR_GREEN] = { 2312, 2371 }, [q.COLOR_BLUE] = { 2377, 2439 } } + local w = WINDOWS[q.iUrsulaColor] + local rel = discGetFrame() - q.thisOffset + + if w and (rel >= w[1] + 3) and (rel <= w[2] - 3) then + -- (The one-player edition builds her rectangles inside its hit test, so before the first + -- shot the game's own numbers stand in.) + local box = (q.ursie and q.ursie[1]) or { 152, 137, 210, 238 } + + targets = { { hmx((box[1] + box[3]) / 2), hmy((box[2] + box[4]) / 2) } } + targetStep = q.lvlPlayClip3 + end + elseif (q.levelFellina1 ~= nil) and (q.currentLevel == q.levelFellina1) and ((q.lvlState == q.lvlPlayPuzzle1) or (q.lvlState == q.lvlPlayPuzzle2)) and q.thingie then + -- Space Pirates's Fellina puzzles: the skull she chose, then the arrow, by the middle of + -- the game's own line. + local which = (q.lvlState == q.lvlPlayPuzzle1) and q.iFellinaSkull or q.iFellinaArrow + local row = q.thingie[which] and q.thingie[which][1] + + if row and ((q.lvlState == q.lvlPlayPuzzle2) or (discGetFrame() >= q.iSkullStart + 3)) then + targets = { { hmx((row[q.lineStartX] + row[q.lineEndX]) / 2), hmy((row[q.lineStartY] + row[q.lineEndY]) / 2) } } + targetStep = q.lvlState + end + elseif (q.levelCrystalBuild ~= nil) and (q.currentLevel == q.levelCrystalBuild) and (q.lvlState == q.lvlRunning) and q.hole and q.socket then + -- Space Pirates's crystal build: the next socket in the order shown. + local ORDERS = { [q.ORDER_RGB] = { q.SOCKET_RED, q.SOCKET_GREEN, q.SOCKET_BLUE }, [q.ORDER_GBR] = { q.SOCKET_GREEN, q.SOCKET_BLUE, q.SOCKET_RED }, [q.ORDER_BRG] = { q.SOCKET_BLUE, q.SOCKET_RED, q.SOCKET_GREEN } } + + for _, which in ipairs(ORDERS[q.iCrystalOrder] or {}) do + if not q.socket[which] then + local row = q.hole[which] and q.hole[which][1] + + if row then + targets = { { hmx((row[q.lineStartX] + row[q.lineEndX]) / 2), hmy((row[q.lineStartY] + row[q.lineEndY]) / 2) } } + end + break + end + end + elseif (q.GetMapArray01 ~= nil) and (q.currentLevel == q.levelMenu) and (q.lvlState == q.lvlRunning) and q.stage then + -- Who Shot Johnny Rock's city map: the killer's place once revealed, the mansion once the + -- clues are all had, else the first place still open (the casino, the pool hall, the + -- warehouse, the garage), by the middle of its panel. + local which = nil + + if q.bKillerRevealed then + which = ({ [q.JILL] = q.PLACE_CASINO, [q.MUMPS] = q.PLACE_POOL, [q.MEASLES] = q.PLACE_WAREHOUSE, [q.POX] = q.PLACE_GARAGE })[q.iKiller] + elseif q.bGotAllClues then + which = q.PLACE_MANSION + elseif not q.stage[q.levelCasino] then + which = q.PLACE_CASINO + elseif not q.stage[q.levelPool] then + which = q.PLACE_POOL + elseif not (q.stage[q.levelWarehouse1] and q.stage[q.levelWarehouse2] and q.stage[q.levelWarehouse3]) then + which = q.PLACE_WAREHOUSE + elseif not q.stage[q.levelGarage] then + which = q.PLACE_GARAGE + end + if which then + q.GetMapArray01() + local row = q.place[which] and q.place[which][1] + + if row then + targets = { { hmx((row[q.lineStartX] + row[q.lineEndX]) / 2), hmy((row[q.lineStartY] + row[q.lineEndY]) / 2) } } + end + end + elseif (q.GetMapArray01 ~= nil) and (q.currentLevel == q.levelMansion2) and (q.lvlState == q.lvlRunning) and q.clue and q.thisItem then + -- Who Shot Johnny Rock's clue items: the item of the clue asked for, in the game's second + -- scale. + q.GetArrayItems() + local row = q.item[q.clue[q.thisItem]] and q.item[q.clue[q.thisItem]][1] + + if row then + targets = { { q.ResHMx2((row[q.lineStartX] + row[q.lineEndX]) / 2), q.ResHMy2((row[q.lineStartY] + row[q.lineEndY]) / 2) } } + end + elseif (q.GetMapArray01 ~= nil) and (q.currentLevel == q.levelMansion4) and (q.lvlState == q.lvlRunning) and q.combination and q.iSafeNumber then + -- Who Shot Johnny Rock's safe: the dial shot while it shows the combination's next digit + -- (on this frame and the next, since the press is judged on the next). + local digit = tonumber(string.sub(q.combination, q.iSafeNumber, q.iSafeNumber)) + local rel = discGetFrame() - q.thisOffset + + if (q.getSafeNumber(rel) == digit) and (q.getSafeNumber(rel + 1) == digit) then + targets = { { hmx((151 + 207) / 2), hmy((64 + 88) / 2) } } + end + elseif (q.GUIDE_BEAVER ~= nil) and (q.ResHMx ~= nil) and (q.currentLevel == q.levelMenu) and q.panel then + -- Mad Dog II's guide menu: the first guide whose trail is not done, by its panel. + targets = {} + local x = (q.panel[q.GUIDE_BEAVER] == false) and 90 or ((q.panel[q.GUIDE_BONNIE] == false) and 186 or ((q.panel[q.GUIDE_PROF] == false) and 282 or nil)) + + if x then + targets[1] = { hmx(x), hmy(121) } + end + end + + local playing = ((q.currentState or q.gameflow) == q.statePlaying) + local intro = playing and (q.currentLevel == (q.lvlIntro or q.levelIntro)) + + -- The service screen: the option under the cursor and every dip, for the trace; a screen + -- the stick drives rather than the pointer (Hologram Time Traveler). + local dips = {} + + for key, value in pairs(q) do + if (type(key) == "string") and (key:sub(1, 4) == "dip_") and (type(value) ~= "function") then + dips[#dips + 1] = key:sub(5) .. "=" .. tostring(value) + end + end + table.sort(dips) + + return { screen = q.currentLevel, step = q.lvlState, move = q.currentMove, frame = discGetFrame(), frames = frames, score = q.iScore, lives = q.iLives or q.iMoney, ammo = q.iBullets, active = q.p1Active, intro = intro, + service = (q.levelService ~= nil) and (q.currentLevel == q.levelService), serviceKeys = (q.TURNRIGHTSHOOT ~= nil), opt = q.optSel, dips = table.concat(dips, ","), width = overlayGetWidth(), height = overlayGetHeight(), + board = (q.levelHighScore ~= nil) and (q.currentLevel == q.levelHighScore), + window = window, box = window and firstBox(q) or nil, quadrants = quadrants, targets = targets, targetStep = targetStep, continuing = (q.currentLevel == q.levelContinue), noSelect = (q.mouseIndexSelect == nil), typing = typing, typed = (q.validKey ~= nil), offscreen = { -5, -5 }, free = q.bAmmo, paused = q.bPause, + -- The one-player editions (no player select, lives rather than money) spend a shot + -- before the level tests for ammo, so their last shot is never judged: a spare to keep. + spare = ((q.mouseIndexSelect == nil) and (q.iMoney == nil) and (q.iBullets ~= nil) and ((q.panelHit == nil) or (q.resX ~= nil)) and (q.levelCharlie == nil)) and 1 or 0, + -- The library's one-player Crime Patrol family judges a shot before the release takes its + -- bullet (its HD twins take it on the press, as the others do), and reloads by its second + -- button (immediately, as its config ships); so does Platoon. + reloadSwitch = (((q.panelHit ~= nil) and (q.mouseIndexSelect == nil)) or (q.levelCharlie ~= nil)) and SWITCH_BUTTON1 or SWITCH_BUTTON3 } +end + + +onOverlayUpdate = function(...) + local result = realUpdate(...) + + algDrive(view()) + + return result +end diff --git a/testScripts/ports/compare.sh b/testScripts/ports/compare.sh new file mode 100755 index 000000000..b2532250c --- /dev/null +++ b/testScripts/ports/compare.sh @@ -0,0 +1,208 @@ +#!/bin/bash +# Runs an original game from the test library and its Forge port on the same inputs, headless +# and on the deterministic clock, and says whether their traces agree (FORGE.md section 14.1). +# +# testScripts/ports/compare.sh <family> <game> +# +# SINGE the engine binary (default: the newest build in .builddir) +# SINGE_LIBRARY the test library (default: ~/claude/singetest/ported, where the +# ported originals live; the hypseus families look under its hypseus/) +# SINGE_RUN a directory to run in, with Singe/ and Forge/ beside each other +# (default: a fresh one under /tmp, with assets/Forge copied in) +# +# Families: actionMax (games 38AmbushAlley, BlueThunder, Hydrosub2021, PopsGhostly, SonicFury); +# karis (a game as directory/script, e.g. TimeGal/Timegal.singe); +# hypseus (a Karis-framework title from the Hypseus library by its folder, e.g. +# Astroboy: the script is the description's namesake under singe/, the video its +# framefile); +# rdg (a map-mode title from the Hypseus library, laid out the same way; the engine's +# deterministic clock gives both runs the same seed for the game's random draws); +# alg (an American Laser Games title, Hypseus layout or the library's); +# videoland (Adventures in Videoland: Rollercoaster, by its folder); +# timetraveler (Hologram Time Traveler, by its folder). +set -u +family=${1:?usage: compare.sh <family> <game>} +game=${2:?usage: compare.sh <family> <game>} +repo=$(cd "$(dirname "$0")/../.." && pwd) +library=${SINGE_LIBRARY:-$HOME/claude/singetest/ported} +binary=${SINGE:-$(ls -t "$repo"/.builddir/linux-gcc/*/singe/Singe-v* "$repo"/.builddir/linux/x86_64/singe/Singe-v* 2>/dev/null | head -1)} +run=${SINGE_RUN:-} +if [[ -z "$run" ]]; then + run=$(mktemp -d /tmp/forgePort.XXXXXX) +fi +# The run directory gets Forge fresh from the repository every time, so an edit to Author.singe +# is what the port plays. +mkdir -p "$run" +rm -rf "$run/Forge" +cp -r "$repo/assets/Forge" "$run/Forge" +if [[ -z "$binary" || ! -x "$binary" ]]; then + echo "compare.sh: no engine binary; set SINGE" >&2 + exit 2 +fi +case "$family" in + actionMax) + video="$library/ActionMax/frame_$game.txt" + original="$repo/testScripts/ports/actionMaxOriginal.singe" + port="$repo/testScripts/ports/actionMaxPort.singe" + ;; + karis) + # The game's directory and its main script, as "TimeGal/Timegal.singe"; the video is + # what its games.dat names. + export PORT_SCRIPT=${game#*/} + game=${game%%/*} + video=$library/$(grep -o 'VIDEO *= *"[^"]*"' "$library/$game/games.dat" | head -1 | sed 's/.*"\(.*\)"/\1/') + original="$repo/testScripts/ports/karisOriginal.singe" + port="$repo/testScripts/ports/karisPort.singe" + ;; + hypseus) + # A Hypseus title's script hardcodes BASEDIR = "singe", so the run directory gets the + # title's singe/ beside it, and its own copy of Forge. + library=${SINGE_LIBRARY:-$HOME/claude/singetest/ported/hypseus} + # The script is the description's namesake, wherever under singe/ the title keeps it + # (Sintel's folder is not named as its title is). + desc=$(find -L "$library/$game/singe" -mindepth 2 -maxdepth 2 -name "*.forge" 2>/dev/null | head -1) + if [[ -n "$desc" ]]; then + export PORT_SCRIPT=${desc#"$library/$game/"} + PORT_SCRIPT=${PORT_SCRIPT%.forge}.singe + else + export PORT_SCRIPT=singe/$game/$game.singe + fi + # The framefile is at the top of the title, or beside the script. + video=$library/$game/$game.txt + [[ -f "$video" ]] || video=$library/$game/${PORT_SCRIPT%.singe}.txt + original="$repo/testScripts/ports/karisOriginal.singe" + port="$repo/testScripts/ports/karisPort.singe" + run=$run/$game + mkdir -p "$run" + rm -rf "$run/Forge" + cp -r "$repo/assets/Forge" "$run/Forge" + ln -sfn "$library/$game/singe" "$run/singe" + ;; + rdg) + library=${SINGE_LIBRARY:-$HOME/claude/singetest/ported/hypseus} + desc=$(find -L "$library/$game/singe" -mindepth 2 -maxdepth 2 -name "*.forge" 2>/dev/null | head -1) + export PORT_SCRIPT=${desc#"$library/$game/"} + PORT_SCRIPT=${PORT_SCRIPT%.forge}.singe + video=$library/$game/$game.txt + [[ -f "$video" ]] || video=$library/$game/${PORT_SCRIPT%.singe}.txt + original="$repo/testScripts/ports/karisOriginal.singe" + port="$repo/testScripts/ports/karisPort.singe" + run=$run/$game + mkdir -p "$run" + rm -rf "$run/Forge" + cp -r "$repo/assets/Forge" "$run/Forge" + ln -sfn "$library/$game/singe" "$run/singe" + ;; + alg) + # 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 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) + else + desc=$(find -L "$library/$game/singe" -mindepth 2 -maxdepth 2 -name "*.forge" 2>/dev/null | head -1) + if [[ -n "$desc" ]]; then + export PORT_SCRIPT=${desc#"$library/$game/"} + PORT_SCRIPT=${PORT_SCRIPT%.forge}.singe + else + export PORT_SCRIPT=singe/$game/$game.singe + fi + video=$library/$game/$game.txt + fi + original="$repo/testScripts/ports/algOriginal.singe" + port="$repo/testScripts/ports/algPort.singe" + run=$run/$game + mkdir -p "$run" + rm -rf "$run/Forge" + cp -r "$repo/assets/Forge" "$run/Forge" + ln -sfn "$library/$game/singe" "$run/singe" + ;; + videoland|timetraveler) + # A one-off of the library (Adventures in Videoland: Rollercoaster; Hologram Time + # Traveler): its games.dat names the video and the script; the description is beside + # the script; the drivers are the family's own. + library=${SINGE_LIBRARY:-$HOME/claude/singetest} + export PORT_SCRIPT=$(grep -o 'SCRIPT *= *"[^"]*"' "$library/$game/games.dat" | head -1 | sed 's/.*"\(.*\)"/\1/') + video=$library/$(grep -o 'VIDEO *= *"[^"]*"' "$library/$game/games.dat" | head -1 | sed 's/.*"\(.*\)"/\1/') + original="$repo/testScripts/ports/${family}Original.singe" + port="$repo/testScripts/ports/${family}Port.singe" + run=$run/$game + mkdir -p "$run" + rm -rf "$run/Forge" + cp -r "$repo/assets/Forge" "$run/Forge" + ;; + *) + echo "compare.sh: no family called $family" >&2 + exit 2 + ;; +esac +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" + 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")" + done + fi + echo "$work/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="" +if [[ -n "${PORT_SCRIPT:-}" ]]; then + cfgDir=$library/$game/$(dirname "$PORT_SCRIPT")/Cfg + [[ -d "$cfgDir" ]] || cfgDir=$library/$game/Cfg + [[ -d "$cfgDir" ]] || cfgDir="" +fi +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. +cfgFiles="" +if [[ "$family" == "alg" ]]; then + cfgFiles=$(ls "$library/$game/$(dirname "$PORT_SCRIPT")"/*.cfg 2>/dev/null) + if [[ -n "$cfgFiles" ]]; then + cfgKeepFiles=$(mktemp -d "$run/cfgfiles.XXXXXX") + cp $cfgFiles "$cfgKeepFiles/" + fi +fi +# The originals' files go back however the comparison ends, a kill included: a run cut short +# would otherwise leave the game's own writes (its board, its pool index) for the next run and +# the description to disagree on. +restoreConfig() { + if [[ -n "$cfgDir" ]]; then + rm -rf "$cfgDir" + cp -r "$cfgKeep" "$cfgDir" + fi + if [[ -n "$cfgFiles" ]]; then + cp "$cfgKeepFiles"/*.cfg "$library/$game/$(dirname "$PORT_SCRIPT")/" + fi +} +trap restoreConfig EXIT +a=$(trace original "$original") +b=$(trace port "$port") +if [[ ! -s "$a" || ! -s "$b" ]]; then + echo "$family $game: a run produced no trace; see $(dirname "$a")/run.log and $(dirname "$b")/run.log" >&2 + exit 2 +fi +if diff -q "$a" "$b" > /dev/null; then + echo "$family $game: the port plays as the original does ($(wc -l < "$a") trace lines; $(tail -1 "$a"))" + exit 0 +fi +echo "$family $game: the traces differ" +diff "$a" "$b" | head -20 +exit 1 diff --git a/testScripts/ports/karisDrive.singe b/testScripts/ports/karisDrive.singe new file mode 100644 index 000000000..1054b387b --- /dev/null +++ b/testScripts/ports/karisDrive.singe @@ -0,0 +1,498 @@ +-- How a KarisFramework game and its Forge port are driven for comparison (FORGE.md section +-- 14.1): the same policy, read off the same state in either. A run presses START on the +-- attract loop, then answers each move a few frames into its window -- the right answer, or a +-- wrong one for every fifth move so deaths and continues are exercised -- and takes the one +-- continue offered. Both scripts hand this file a view of their state (karisView) and a way +-- to press and release a switch (karisPress); this file does the deciding. +-- +-- 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 + +local lastLine = nil +local pressed = nil -- The switch held down this frame, released the next. +local started = false +local coined = false +local continued = false +local answered = {} -- Windows already answered, by "level/scene/move/lives". +local tried = {} -- Moves met before, by "level/scene/move": a retry after a death. + +-- What the drive does with a move, so that every path of a loop is walked: most moves are +-- answered right; every fifth is answered wrong (a stray input, a wrong option, a mash +-- broken by a stray input); every seventh is left unanswered (the window runs out). A move +-- 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 +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 +end +local function missedMove(view) + return (view.move % MISS_EVERY == 0) and (view.move % WRONG_EVERY ~= 0) and not view.retry +end +local seeks = 0 +local plan = nil -- Kimmy: the presses planned for the window being answered. +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 + +-- 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 } +local PAIR_OF = { [9] = { 1, 3 }, [10] = { 1, 4 }, [11] = { 2, 3 }, [12] = { 2, 4 } } +local KIMMY = { COMBO = 20, MULTI = 21, LOOP = 22, MASH = 23, HOLD = 24, LETGO = 25, PATH = 51, YESNO = 52, TIMED = 53, ANYTHING = 54, NOTHING = 55, UD = 151, LR = 153, B1B2 = 155 } + +-- The KarisFramework's compound kinds, and the MazescaterFramework's (LINEA), as what they +-- take, by the framework's numbers: a sequence's inputs in turn (a circle is five, back to +-- its start), a mash's input (or the two it alternates), a hold's input, and the inputs a +-- combination wants together. +local SEQ_OF = { [35] = { 3, 2, 4, 1, 3 }, [36] = { 4, 2, 3, 1, 4 }, + [156] = { 1, 3 }, [157] = { 1, 4 }, [158] = { 2, 3 }, [159] = { 2, 4 }, [160] = { 3, 1 }, [161] = { 4, 1 }, [162] = { 3, 2 }, [163] = { 4, 2 }, + [170] = { 4, 3 }, [171] = { 3, 4 }, [172] = { 1, 2 }, [173] = { 2, 1 }, + [149] = { 2, 3, 5 }, [150] = { 2, 4, 5 }, [151] = { 1, 3, 5 }, [152] = { 1, 4, 5 }, [164] = { 3, 1, 5 }, [165] = { 4, 1, 5 }, [166] = { 4, 2, 5 }, [167] = { 3, 2, 5 }, + [168] = { 2, 3, 6 }, [169] = { 2, 4, 6 }, [174] = { 1, 3, 6 }, [175] = { 1, 4, 6 }, [176] = { 3, 1, 6 }, [177] = { 4, 1, 6 }, [178] = { 4, 2, 6 }, [179] = { 3, 2, 6 }, + [180] = { 2, 3, 1 }, [181] = { 2, 4, 1 }, [182] = { 3, 2, 4 }, [183] = { 3, 1, 4 }, [184] = { 4, 2, 3 }, [185] = { 4, 1, 3 }, [186] = { 1, 3, 2 }, [187] = { 1, 4, 2 }, + [216] = { 3, 2, 4, 1, 3 }, [219] = { 2, 4, 1, 3, 2 }, [218] = { 4, 1, 3, 2, 4 }, [217] = { 1, 3, 2, 4, 1 }, + [212] = { 3, 1, 4, 2, 3 }, [213] = { 1, 4, 2, 3, 1 }, [214] = { 4, 2, 3, 1, 4 }, [215] = { 2, 3, 1, 4, 2 } } +local MASH_OF = { [17] = { 5 }, [18] = { 5 }, [19] = { 5 }, [27] = { 3, 4 }, [28] = { 3, 4 }, [29] = { 3, 4 }, [30] = { 5, 6 }, [31] = { 5, 6 }, [32] = { 5, 6 }, + [153] = { 5, 7 }, [154] = { 5, 7 }, [155] = { 5, 7 }, [188] = { 6 }, [189] = { 6 }, [190] = { 6 }, [192] = { 7 }, [193] = { 7 }, [194] = { 7 }, + [196] = { 3 }, [197] = { 3 }, [198] = { 3 }, [200] = { 4 }, [201] = { 4 }, [202] = { 4 }, [204] = { 1 }, [205] = { 1 }, [206] = { 1 }, [208] = { 2 }, [209] = { 2 }, [210] = { 2 } } +local HOLD_OF = { [21] = 1, [22] = 2, [23] = 3, [24] = 4, [25] = 5 } +local TOGETHER = { [9] = { 1, 3 }, [10] = { 1, 4 }, [11] = { 2, 3 }, [12] = { 2, 4 }, [13] = { 5, 1 }, [14] = { 5, 2 }, [15] = { 5, 3 }, [16] = { 5, 4 }, + [220] = { 6, 1 }, [221] = { 6, 2 }, [222] = { 6, 3 }, [223] = { 6, 4 }, [224] = { 7, 1 }, [225] = { 7, 2 }, [226] = { 7, 3 }, [227] = { 7, 4 }, + [228] = { 5, 1, 3 }, [229] = { 5, 1, 4 }, [230] = { 5, 2, 3 }, [231] = { 5, 2, 4 }, [232] = { 6, 1, 3 }, [233] = { 6, 1, 4 }, [234] = { 6, 2, 3 }, [235] = { 6, 2, 4 }, + [236] = { 7, 1, 3 }, [237] = { 7, 1, 4 }, [238] = { 7, 2, 3 }, [239] = { 7, 2, 4 } } +local KARIS_LETGO, KARIS_MULTI = 20, 33 + + +-- The switches an input number stands for. +local function switchesOf(kind) + if PAIR_OF[kind] then + return { SWITCH_OF[PAIR_OF[kind][1]], SWITCH_OF[PAIR_OF[kind][2]] } + end + + return { SWITCH_OF[kind] } +end + + +-- A plan's steps: what to press and let go on which frame. +local function planPress(steps, frame, switches) + steps[frame] = steps[frame] or { press = {}, release = {} } + for _, s in ipairs(switches) do + table.insert(steps[frame].press, s) + end +end +local function planRelease(steps, frame, switches) + steps[frame] = steps[frame] or { press = {}, release = {} } + for _, s in ipairs(switches) do + table.insert(steps[frame].release, s) + end +end +local function planTap(steps, frame, switches) + planPress(steps, frame, switches) + planRelease(steps, frame + 1, switches) +end + + +-- A switch a move does not accept, for a wrong answer. +local function wrongFor(accepted) + for _, candidate in ipairs({ 2, 1, 3, 4, 6 }) do + local ok = true + + for _, a in ipairs(accepted) do + if a == candidate then + ok = false + end + if PAIR_OF[a] and ((PAIR_OF[a][1] == candidate) or (PAIR_OF[a][2] == candidate)) then + ok = false + end + end + if ok then + return { SWITCH_OF[candidate] } + end + end + + return { SWITCH_BUTTON2 } +end + + +-- A plan: what to press and let go on which frames, for one window. +local function planFor(view) + local w = view.window + local at = w.from + 3 + local steps = {} + local wrong = wrongMove(view) + local kind = w.kind + + if missedMove(view) then + -- Left unanswered: the window runs out. + elseif ((kind >= 1) and (kind <= 7)) or ((kind >= 9) and (kind <= 12)) then + if wrong then + planTap(steps, at, wrongFor({ kind, w.alt })) + else + planTap(steps, at, switchesOf(kind)) + end + elseif (kind == KIMMY.MASH) and wrong then + -- A mash broken by a stray input. + planTap(steps, at, wrongFor({ w.alt or 5 })) + elseif kind == KIMMY.COMBO then + local both = switchesOf(w.alt) + + for _, s in ipairs(switchesOf(w.alt2)) do + both[#both + 1] = s + end + planTap(steps, at, both) + elseif kind == KIMMY.HOLD then + planPress(steps, at, switchesOf(w.alt)) + planRelease(steps, w.to, switchesOf(w.alt)) + elseif kind == KIMMY.LETGO then + planPress(steps, w.from - 4, { SWITCH_BUTTON1 }) + planRelease(steps, w.from + 2, { SWITCH_BUTTON1 }) + elseif kind == KIMMY.MASH then + local turn = 0 + + for frame = at, w.to - 2, 2 do + local which + + if w.alt == KIMMY.UD then + which = (turn % 2 == 0) and { SWITCH_UP } or { SWITCH_DOWN } + elseif w.alt == KIMMY.LR then + which = (turn % 2 == 0) and { SWITCH_LEFT } or { SWITCH_RIGHT } + elseif w.alt == KIMMY.B1B2 then + which = (turn % 2 == 0) and { SWITCH_BUTTON1 } or { SWITCH_BUTTON2 } + else + which = switchesOf(w.alt or 5) + end + planTap(steps, frame, which) + turn = turn + 1 + end + elseif (kind == KIMMY.MULTI) or (kind == KIMMY.LOOP) then + local start = at + ((kind == KIMMY.MULTI) and (w.alt2 or 0) or 0) + + for i, step in ipairs(view.multi or {}) do + planTap(steps, start + 2 * (i - 1), switchesOf(step)) + end + elseif kind == KIMMY.PATH then + local p = view.path or {} + + if wrong then + planTap(steps, at, wrongFor({ p[1], p[3], p[5], p[7] })) + elseif p[1] and (p[1] > 0) then + planTap(steps, at, switchesOf(p[1])) + end + elseif kind == KIMMY.YESNO then + if view.move % 2 == 0 then + planTap(steps, at, { SWITCH_BUTTON1 }) + end + elseif kind == KIMMY.TIMED then + local first = (view.timed or {})[w.alt or view.move] + + if first and (first[2] + 1 >= w.from) and (first[2] + 1 <= w.to) then + planTap(steps, first[2] + 1, switchesOf(first[1])) + end + elseif kind == KIMMY.ANYTHING then + planTap(steps, at, { SWITCH_BUTTON1 }) + elseif kind == KIMMY.NOTHING then + if wrong then + planTap(steps, at, { SWITCH_BUTTON1 }) + end + end + + return { steps = steps, from = w.from - 6, to = w.to + 2 } +end + + +-- A map-mode plan: the ancestor's kinds. A single input tapped; MASH (8) tapped every other +-- frame; an ACT combo (9 to 12) as the button with a direction; a diagonal (13 to 16) as two +-- directions; SKIP (17) left alone. Every fifth move is answered wrong. +local RDG_ACT = { [9] = { 5, 1 }, [10] = { 5, 2 }, [11] = { 5, 3 }, [12] = { 5, 4 }, [13] = { 1, 3 }, [14] = { 1, 4 }, [15] = { 2, 3 }, [16] = { 2, 4 } } + +local function planForRdg(view) + local w = view.window + local at = w.from + 3 + local steps = {} + local wrong = wrongMove(view) + local kind = w.kind + + + if missedMove(view) then + -- Left unanswered: the window runs out. + elseif (kind >= 1) and (kind <= 7) then + if wrong then + planTap(steps, at, { (kind == 1) and SWITCH_DOWN or SWITCH_UP }) + else + planTap(steps, at, { SWITCH_OF[kind] }) + end + elseif (kind == (view.mashKind or 8)) and wrong then + -- A mash broken by a stray direction. + planTap(steps, at, { SWITCH_UP }) + elseif kind == (view.mashKind or 8) then + for frame = at, w.to - 2, 2 do + planTap(steps, frame, { SWITCH_BUTTON1 }) + end + elseif (view.pairs or RDG_ACT)[kind] and view.zone then + -- A shot: the pointer into the zone (or well outside it) the frame before the button, + -- in the raw coordinates the game scales by its ratios. + local z = view.zone + local r = view.mouse or { 1, 1, 0, 0 } + local mx = wrong and (z[3] + 200) or ((z[1] + z[3]) / 2) + local my = wrong and (z[4] + 200) or ((z[2] + z[4]) / 2) + + steps[at - 1] = steps[at - 1] or { press = {}, release = {} } + steps[at - 1].mouse = { math.floor((mx + r[3]) / r[1]), math.floor((my + r[4]) / r[2]) } + planTap(steps, at, { SWITCH_BUTTON1 }) + elseif (view.pairs or RDG_ACT)[kind] then + local pair = (view.pairs or RDG_ACT)[kind] + + if wrong then + planTap(steps, at, { SWITCH_BUTTON2 }) + else + planTap(steps, at, { SWITCH_OF[pair[1]], SWITCH_OF[pair[2]] }) + end + end + + return { steps = steps, from = w.from - 6, to = w.to + 2 } +end + + +-- A KarisFramework plan for a compound move: a hold pressed from three frames into the +-- window to its end; a let-go held from before the window and released within it; a mash +-- tapped every other frame (a run, MASH2, and MASH3 alternating their two); a sequence or a +-- circle a step every other frame; a multi tapped as many times as its row asks; a +-- combination pressed together. Every fifth move is answered wrong (a stray input, or +-- nothing for a let-go); every seventh is left unanswered. +local function planForKaris(view) + local w = view.window + local at = w.from + 3 + local steps = {} + local wrong = wrongMove(view) + local kind = w.kind + + local function switches(list) + local result = {} + + for _, m in ipairs(list) do + result[#result + 1] = SWITCH_OF[m] + end + + return result + end + + if missedMove(view) then + -- Left unanswered: the window runs out. + elseif HOLD_OF[kind] then + if wrong then + planTap(steps, at, wrongFor({ HOLD_OF[kind] })) + else + planPress(steps, at, { SWITCH_OF[HOLD_OF[kind]] }) + planRelease(steps, w.to, { SWITCH_OF[HOLD_OF[kind]] }) + end + elseif kind == KARIS_LETGO then + if not wrong then + planPress(steps, w.from - 4, { SWITCH_BUTTON1 }) + planRelease(steps, w.from + 2, { SWITCH_BUTTON1 }) + end + elseif MASH_OF[kind] then + if wrong then + planTap(steps, at, wrongFor(MASH_OF[kind])) + else + local turn = 0 + + for frame = at, w.to - 2, 2 do + planTap(steps, frame, { SWITCH_OF[MASH_OF[kind][(turn % #MASH_OF[kind]) + 1]] }) + turn = turn + 1 + end + end + elseif SEQ_OF[kind] then + if wrong then + planTap(steps, at, wrongFor(SEQ_OF[kind])) + else + for i, m in ipairs(SEQ_OF[kind]) do + planTap(steps, at + 2 * (i - 1), { SWITCH_OF[m] }) + end + end + elseif kind == KARIS_MULTI then + if wrong then + planTap(steps, at, wrongFor({ w.alt })) + else + for i = 1, (w.alt2 or 1) do + planTap(steps, at + 2 * (i - 1), { SWITCH_OF[w.alt] }) + end + end + elseif TOGETHER[kind] then + if wrong then + planTap(steps, at, wrongFor(TOGETHER[kind])) + else + planTap(steps, at, switches(TOGETHER[kind])) + end + end + + return { steps = steps, from = w.from - 6, to = w.to + 2 } +end + + +-- A choice: step down to the right option (a wrong one every fifth move), then take it. +function answerChoice(view, key, w) + if (view.frame >= w.from + 3) and (view.frame <= w.to) then + local want = nil + local wrong = wrongMove(view) + + for at, index in ipairs(view.order or {}) do + if view.choices[index] and (view.choices[index].right ~= wrong) and (want == nil or not wrong) then + want = at + end + end + if want and (view.choice < want) then + pressed = SWITCH_DOWN + karisPress(pressed, true) + elseif want and (view.choice == want) and not answered[key] then + answered[key] = true + pressed = SWITCH_BUTTON1 + karisPress(pressed, true) + end + end +end + + +-- Runs the plan for this frame. +local function followPlan(frame) + local step = plan and plan.steps[frame] + + if step then + if step.mouse then + karisMouse(step.mouse[1], step.mouse[2]) + end + for _, s in ipairs(step.press) do + if not held[s] then + held[s] = true + karisPress(s, true) + end + end + for _, s in ipairs(step.release) do + if held[s] then + held[s] = nil + karisPress(s, false) + end + end + end + if plan and (frame > plan.to) then + for s in pairs(held) do + karisPress(s, false) + end + held = {} + plan = nil + end +end + + +function karisTraceSeek(frame) + seeks = seeks + 1 + debugPrint(string.format("TRACE seek %d", frame)) +end + + +-- Presses what the policy says this frame. view: { screen, step, level, scene, move, frame, +-- window = { from, to, kind, death, alt }, choices = { { right }, ... }, order, choice }. +function karisDrive(view) + if pressed then + karisPress(pressed, false) + pressed = nil + 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 line ~= lastLine then + lastLine = line + debugPrint(line .. " disc " .. tostring(view.frame)) + 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 + 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 + started = true + pressed = SWITCH_START1 + karisPress(pressed, true) + return + end + -- Kimmy's menus: the new-game menu (a level the framework never numbered, so nil) takes + -- the button for NEW GAME; the level select takes the button for the level shown. + if view.kimmy and started and ((view.screen == nil) or (view.screen == 117)) and (view.frame ~= menuAt) and (view.frames > menuAt + 30) then + menuAt = view.frames + pressed = SWITCH_BUTTON1 + karisPress(pressed, true) + return + end + -- The continue screen: taken once. + if (view.screen == 100) and (view.step == 1) and not continued then + continued = true + pressed = SWITCH_START1 + karisPress(pressed, true) + return + end + if (view.screen ~= 103) or (view.window == nil) then + if plan then + followPlan(view.frame) + end + return + end + -- A move is answered once per life: a game that rewinds to the move after a death + -- (the later map-mode copies) asks it again, and the drive answers it right that time. + local moveKey = tostring(view.level) .. "/" .. tostring(view.scene) .. "/" .. tostring(view.move) + local key = moveKey .. "/" .. tostring(view.lives) + local w = view.window + + view.retry = (tried[moveKey] ~= nil) + + if view.kimmy or view.rdg then + -- A Kimmy or map-mode move: a plan of presses for the window, made when it comes into + -- view. + if (view.step == 1) and (plan == nil) and not answered[key] and (view.frame >= w.from - 6) and (view.frame <= w.to) then + answered[key] = true + tried[moveKey] = true + plan = view.rdg and planForRdg(view) or planFor(view) + elseif view.rdg and (view.step == ((view.timegal and 12) or 11)) and view.choices then + answerChoice(view, key, w) + end + followPlan(view.frame) + elseif view.step == 1 then + local kind = w.kind + + if (kind >= 1) and (kind <= 7) then + -- Waiting for a single input: answer three frames into the window. + if (view.frame >= w.from + 3) and (view.frame <= w.to) and not answered[key] then + answered[key] = true + tried[moveKey] = true + if not missedMove(view) then + local wrong = wrongMove(view) + local press = wrong and ((kind == 1) and SWITCH_DOWN or SWITCH_UP) or SWITCH_OF[kind] + + pressed = press + karisPress(press, true) + end + end + elseif (plan == nil) and not answered[key] and (view.frame >= w.from - 6) and (view.frame <= w.to) and (HOLD_OF[kind] or MASH_OF[kind] or SEQ_OF[kind] or TOGETHER[kind] or (kind == KARIS_LETGO) or (kind == KARIS_MULTI)) then + -- A compound move: a plan of presses over the window, made as it comes into view. + answered[key] = true + tried[moveKey] = true + plan = planForKaris(view) + end + followPlan(view.frame) + elseif (view.step == 19) and view.choices then + answerChoice(view, key, w) + elseif plan then + -- The move is judged: what the plan still holds is let go, and the rest of it dropped. + for s in pairs(held) do + karisPress(s, false) + end + held, plan = {}, nil + end +end + + +-- The run is long enough when the game has been played through a continue and out. +function karisDone(view) + return continued and (view.screen == 0) and (seeks > 0) +end diff --git a/testScripts/ports/karisOriginal.singe b/testScripts/ports/karisOriginal.singe new file mode 100644 index 000000000..2a4d92958 --- /dev/null +++ b/testScripts/ports/karisOriginal.singe @@ -0,0 +1,106 @@ +-- A KarisFramework game as it ships, driven by karisDrive.singe and traced. PORT_GAME names +-- the game's directory in the library and PORT_SCRIPT its main script; run with -v the video +-- its games.dat names. +local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "") +local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest/ported") +local game = os.getenv("PORT_GAME") or "TimeGal" +local script = library .. "/" .. game .. "/" .. (os.getenv("PORT_SCRIPT") or "Timegal.singe") + +dofile(here .. "karisDrive.singe") + +-- What its launcher entry and the engine would have given it, and a seed both runs share. +SINGE_LEGACY_SPRITE_ARGS = true +local realTime = os.time +os.time = function(...) return KARIS_SEED end +local realScriptPath = singeGetScriptPath +singeGetScriptPath = function() return script end +local realSkip = discSkipToFrame +discSkipToFrame = function(frame) + karisTraceSeek(frame) + return realSkip(frame) +end +dofile(script) +singeGetScriptPath = realScriptPath + +local realUpdate = onOverlayUpdate +local frames = 0 + + +function karisPress(switch, down) + if down then + onInputPressed(switch) + else + onInputReleased(switch) + end +end + + +function karisMouse(x, y) + if onMouseMoved then + onMouseMoved(x, y, 0, 0, 0) + end +end + + +local function view() + local m = move and move[currentMove or 0] or nil + local window = nil + local chosen = nil + + -- A Kimmy game (acombo is its own) hands over the sequences and branches of the move too; + -- a map-mode game (segment is its own) says so, for its own numbering of the kinds. + local kimmy = (acombo ~= nil) + local timegal = (STAGEMAX ~= nil) + local rdg = ((segment ~= nil) or timegal) and (acombo == nil) + + if timegal then + -- Time Gal's row: clip start, window start, window end, clip end, death clip, kind. + if (currentLevel == levelNormal) and m and (m[2] ~= nil) then + window = { from = m[2], to = m[3], kind = m[7], death = 0, alt = 0, alt2 = 0 } + end + elseif (currentLevel == levelNormal) and m and (m[1] ~= nil) then + window = { from = m[1], to = m[2], kind = m[3], death = m[4], alt = m[5], alt2 = m[6] } + end + -- A map-mode shot: a down action whose row carries a zone, and the game's pointer ratios. + local zone = (m and (m[3] == ACTDOWN) and (m[7] ~= nil)) and { m[7], m[8], m[9], m[10] } or nil + local mouse = ratiox and { ratiox, ratioy, ratioxOffset, ratioyOffset } or nil + -- The pair kinds by the game's own numbers (Super Don Quixote numbers them as Karis does). + 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 } } + 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 + chosen = {} + for index, c in ipairs(choice) do + chosen[index] = { right = c[2] } + end + end + if timegal and (lvlState == branch03) and opt then + chosen = {} + for index, c in ipairs(opt) do + chosen[index] = { right = c[2] } + end + end + + 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 } +end + + +function onOverlayUpdate() + frames = frames + 1 + if gameflow == flow_GameRunning then + karisDrive(view()) + end + local r = realUpdate() + + if (frames > 100) and karisDone(view()) or (frames > KARIS_FRAMES) then + debugPrint("TRACE end after " .. frames .. " frames") + singeQuit() + end + + return r +end diff --git a/testScripts/ports/karisPort.singe b/testScripts/ports/karisPort.singe new file mode 100644 index 000000000..f7fd22d53 --- /dev/null +++ b/testScripts/ports/karisPort.singe @@ -0,0 +1,101 @@ +-- The Forge port of a KarisFramework game, driven and traced as the original is. +local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "") +QTE_LEVEL_FINISH = 110 +local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest/ported") +local game = os.getenv("PORT_GAME") or "TimeGal" +local name = (os.getenv("PORT_SCRIPT") or "Timegal.singe"):gsub("%.singe$", "") +local stem = name:match("([^/\\]+)$") + +dofile("Forge/AuthorCompile.singe") +dofile(here .. "karisDrive.singe") + +local realSkip = discSkipToFrame +discSkipToFrame = function(frame) + karisTraceSeek(frame) + return realSkip(frame) +end +dofile(authorBuild(library .. "/" .. game .. "/" .. name .. ".forge", singeGetDataPath() .. stem .. ".port.singe")) + +local realUpdate = onOverlayUpdate +local frames = 0 + + +function karisPress(switch, down) + if down then + authorSwitchDown(switch) + else + authorSwitchUp(switch) + end +end + + +function karisMouse(x, y) + onMouseMoved(x, y, 0, 0, 0) +end + + +local function view() + local q = authorEntity("game").q + local m = q.move[q.currentMove] + local window = nil + local chosen = nil + + if q.timegal 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 + elseif (q.currentLevel == 103) and m and (m[1] ~= nil) then + window = { from = m[1], to = m[2], kind = m[3], death = m[4], alt = m[5], alt2 = m[6] } + end + local zone = (m and (m[3] == q.ACTDOWN) and (m[7] ~= nil)) and { m[7], m[8], m[9], m[10] } or nil + 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 } } + end + if ((q.lvlState == 19) or (q.rdg 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 + chosen = {} + for index, c in ipairs(q.opt) do + chosen[index] = { right = c[2] } + end + end + + -- Kimmy's new-game menu is a level the framework never numbered (nil there); the port + -- numbers it, and reports it as the original does. + local screen = q.currentLevel + + if q.kimmy 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 + 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 } +end + + +function onOverlayUpdate() + frames = frames + 1 + if authorEntity("game").q.gameflow == "running" then + karisDrive(view()) + end + local r = realUpdate() + + if (frames > 100) and karisDone(view()) or (frames > KARIS_FRAMES) then + debugPrint("TRACE end after " .. frames .. " frames") + singeQuit() + end + + return r +end diff --git a/testScripts/ports/launch.sh b/testScripts/ports/launch.sh new file mode 100755 index 000000000..6f7cbd84b --- /dev/null +++ b/testScripts/ports/launch.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Launches every port in the ports folder from its games.dat, headless on a virtual screen and +# the deterministic clock, for a while each, and counts the runs that printed no error +# (FORGE.md section 14.8). Coverage, not a comparison: compare.sh is the comparison. +# +# testScripts/ports/launch.sh [<ports folder>] +# +# SINGE the engine binary (default: the newest build in .builddir) +# SINGE_RUN a directory to run in (default: a fresh one under /tmp) +# SECONDS_EACH how long each port runs (default: 25) +# JOBS ports run at once (default: 2; four 1080p games on Xvfb ran a +# 30 GB machine out of memory) +set -u +ports=${1:-$HOME/claude/singePorts} +repo=$(cd "$(dirname "$0")/../.." && pwd) +binary=${SINGE:-$(ls -t "$repo"/.builddir/linux-gcc/*/singe/Singe-v* "$repo"/.builddir/linux/x86_64/singe/Singe-v* 2>/dev/null | head -1)} +run=${SINGE_RUN:-$(mktemp -d /tmp/forgePorts.XXXXXX)} +each=${SECONDS_EACH:-25} +jobs=${JOBS:-2} +if [[ -z "$binary" || ! -x "$binary" ]]; then + echo "launch.sh: no engine binary; set SINGE" >&2 + exit 2 +fi +binary=$(realpath "$binary") # The runs change directory. +# SDL's offscreen driver has a 1024x768 desktop, smaller than the KarisFramework games ask for. +export SDL_VIDEODRIVER=x11 SDL_AUDIO_DRIVER=dummy +one() { + local script=$1 video=$2 + local stem=$(basename "$script" .port.singe) + local work=$run/$stem + rm -rf "$work" + mkdir -p "$work" + ( cd "$run" && timeout -s INT "$each" xvfb-run -a -s "-screen 0 1920x1200x24" "$binary" -w -p -k --deterministic -d "$work" -v "$ports/$video" "$ports/$script" ) > "$work/run.log" 2>&1 + local rc=$? + # ffmpeg's hwaccel fallback notice and the X server going away under the timeout's INT are + # the harness, not the game. + local bad=$(grep -a "rror\|Author:\|raceback\|FAIL\|does not exist\|Unable to" "$work/run.log" | grep -vc "hwaccel\|XIO:") + if [[ "$bad" = 0 && ( $rc = 124 || $rc = 0 ) ]]; then + echo "$stem clean" + else + echo "$stem rc=$rc problems=$bad: $(grep -a "rror\|Author:\|raceback\|does not exist\|Unable to" "$work/run.log" | grep -v "hwaccel\|XIO:" | head -2 | tr '\n' ' ')" + fi +} +export -f one +export run each binary ports +for dat in "$ports"/*/games.dat; do + paste -d' ' <(grep -o 'SCRIPT *= *"[^"]*"' "$dat" | sed 's/.*"\(.*\)"/\1/') <(grep -o 'VIDEO *= *"[^"]*"' "$dat" | sed 's/.*"\(.*\)"/\1/') +done | xargs -P "$jobs" -L 1 bash -c 'one $0 $1' | tee "$run/results.txt" +echo "launch.sh: $(grep -c ' clean$' "$run/results.txt") of $(wc -l < "$run/results.txt") ports ran clean; logs under $run" diff --git a/testScripts/ports/shots.sh b/testScripts/ports/shots.sh new file mode 100755 index 000000000..7bfadc2e3 --- /dev/null +++ b/testScripts/ports/shots.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Runs an original and its port through compare.sh with the drive's screenshots and service walk +# on, and gathers the pictures side by side under screenshots/ports/<game>/ in the repository: +# original-shot-<moment>.png beside port-shot-<moment>.png for the attract loop, the service +# screen, a level just after a shot, the continue, and the board (FORGE.md section 14.15). +# +# testScripts/ports/shots.sh <family> <game> [frames] +# +# The comparison's own verdict (the traces agreeing, service walk included) is printed as well. +set -u +family=${1:?usage: shots.sh <family> <game> [frames]} +game=${2:?usage: shots.sh <family> <game> [frames]} +frames=${3:-8000} +repo=$(cd "$(dirname "$0")/../.." && pwd) +name=${game##*/} +name=${name%.singe} +out=$repo/screenshots/ports/$name +rm -rf "$out" +SINGE_SHOTS=$out PORT_SHOTS=1 PORT_SERVICE=1 PORT_FRAMES=$frames "$repo/testScripts/ports/compare.sh" "$family" "$game" +status=$? +ls "$out" 2>/dev/null | sed "s|^|$out/|" +exit $status diff --git a/testScripts/ports/sweep.sh b/testScripts/ports/sweep.sh new file mode 100755 index 000000000..ee7ac50be --- /dev/null +++ b/testScripts/ports/sweep.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# Compares every port with its original for a while each (FORGE.md section 14.1), two pairs at +# a time, and prints one line per title: the ActionMax five, the KarisFramework games and the +# map-mode games of the library, and the Hypseus titles (Karis 3.32b, Kimmy, map-mode, and Time +# Gal HD), each through compare.sh's family for it. +# +# testScripts/ports/sweep.sh [<frames>] (default 6000; 0 for the full length) +# +# SINGE, SINGE_RUN as compare.sh takes them; the library is ~/claude/singetest. +set -u +frames=${1:-6000} +repo=$(cd "$(dirname "$0")/../.." && pwd) +library=$HOME/claude/singetest +run=${SINGE_RUN:-$(mktemp -d /tmp/forgeSweep.XXXXXX)} +export SINGE_RUN=$run +if [[ "$frames" != "0" ]]; then + export PORT_FRAMES=$frames +fi +jobs=() +# One pair: family, game, and the library it lives in. +pair() { + local family=$1 game=$2 lib=$3 + local out + # Each pair runs in a directory of its own: compare.sh refreshes Forge into its run + # directory at every start, which would pull it from under a pair already running there. + out=$(SINGE_RUN=$run/${game//\//_} SINGE_LIBRARY=$lib "$repo/testScripts/ports/compare.sh" "$family" "$game" 2>&1 | grep -a -v "hwaccel\|XIO" | grep -a "plays as\|differ\|no trace\|compare.sh" | tail -1) + echo "${out:-$family $game: no result}" +} +# Two at a time: three engines have run the machine out of memory. +throttle() { + while (( $(jobs -rp | wc -l) >= 2 )); do + sleep 5 + done +} +for game in 38AmbushAlley BlueThunder Hydrosub2021 PopsGhostly SonicFury; do + throttle + pair actionMax "$game" "$library/ported" & +done +for desc in "$library"/ported/*/*.forge; do + folder=$(basename "$(dirname "$desc")") + [[ "$folder" == "ActionMax" || "$folder" == "hypseus" ]] && continue + throttle + pair karis "$folder/$(basename "${desc%.forge}").singe" "$library/ported" & +done +# The map-mode games keep their script in Script/ (in ported/ once they agree, in the library +# root until then). +for lib in "$library/ported" "$library"; do + for desc in "$lib"/*/Script/*.forge; do + [[ -f "$desc" ]] || continue + folder=$(basename "$(dirname "$(dirname "$desc")")") + throttle + pair karis "$folder/Script/$(basename "${desc%.forge}").singe" "$lib" & + done +done +for title in "$library"/ported/hypseus/*/; do + title=$(basename "$title") + desc=$(find -L "$library/ported/hypseus/$title/singe" -mindepth 2 -maxdepth 2 -name "*.forge" 2>/dev/null | head -1) + [[ -n "$desc" ]] || continue + family=hypseus + if grep -a -q 'kind = "rdg"\|kind = "timegal"' "$desc"; then + family=rdg + elif grep -a -q 'kind = "alg"' "$desc"; then + family=alg + fi + throttle + pair "$family" "$title" "$library/ported/hypseus" & +done +wait diff --git a/testScripts/ports/timetravelerDrive.singe b/testScripts/ports/timetravelerDrive.singe new file mode 100644 index 000000000..b07b3a2bc --- /dev/null +++ b/testScripts/ports/timetravelerDrive.singe @@ -0,0 +1,200 @@ +-- Drives Hologram Time Traveler (the original or its port) from what the game shows, the same +-- way for both. The driver file defines what it takes: a view() of the game's state and +-- ttPress(switch, down) to press or let go of a switch; this file decides. The trace lines +-- it prints are what compare.sh diffs. +-- +-- The policy: a coin and START at the attract loop; in a level, the move the row asks for +-- three frames into its window (a turn first where the row wants a turn and a shot), a wrong +-- one on every fifth move and none on every seventh with misses on; a cube against the first +-- death with misses on; at the map, the trader once with misses on (a coin buys a cube) and +-- the timer's own choice otherwise; a pull at the hellgate's lever; a coin and START at the +-- continue; a letter at the board. + +TT_FRAMES = tonumber(os.getenv("PORT_FRAMES")) or 60000 + +local perfect = (os.getenv("PORT_PERFECT") == "1") +local frames = 0 +local lastLine = nil +local pending = nil -- { switch } pressed this frame, let go the next. +local answered = {} -- Windows answered, by level/segment/move/lives. +local tried = {} -- Moves met before, by level/segment/move. +local coined = false +local started = false +local cubeUsed = false +local traded = false +local pulled = false +local lettered = false +local continues = 0 +local turnAt = nil -- The frame the turn went in, for the shot after it. + +local SWITCH_OF = nil + + +function ttTraceSeek(frame) + debugPrint(string.format("TRACE seek %d", frame)) +end + + +local function tap(switch) + ttPress(switch, true) + pending = switch +end + + +-- view: { flow, screen, step, alt, level, seg, move, moves, frame, score, lives, cubes, credits, +-- window = { from, to }, correct (the row's move, or nil for the tutorial's choice), +-- turned (the turn is in), dying, wizard, mapWait, mapCursor, trader, hellgate, +-- continuing, board } +function ttDrive(view) + if pending then + ttPress(pending, false) + pending = nil + end + frames = frames + 1 + if SWITCH_OF == nil then + SWITCH_OF = { [view.UP] = SWITCH_UP, [view.DOWN] = SWITCH_DOWN, [view.LEFT] = SWITCH_LEFT, [view.RIGHT] = SWITCH_RIGHT, [view.ACTION] = SWITCH_BUTTON1 } + end + -- (altState is left out: the original's LCD drawing steps it while the attract loop shows.) + local line = string.format("TRACE flow %s screen %s step %s level %s seg %s move %s score %s lives %s cubes %s credits %s", + tostring(view.flow), tostring(view.screen), tostring(view.step), tostring(view.level), tostring(view.seg), + tostring(view.move), tostring(view.score), tostring(view.lives), tostring(view.cubes), tostring(view.credits)) + + if line ~= lastLine then + debugPrint(line .. " disc " .. tostring(view.frame)) + lastLine = line + end + if frames >= TT_FRAMES then + debugPrint("TRACE end after " .. frames .. " frames") + singeQuit() + return + end + -- The attract loop: a coin, then START once the credit shows. + if view.intro then + if (frames > 200) and not coined then + coined = true + tap(SWITCH_COIN1) + return + end + if coined and (frames > 300) and (frames % 40 == 0) and (view.credits > 0) then + tap(SWITCH_START1) + end + return + end + -- The continue: a coin, then START, taken once. + if view.continuing then + if (continues < 1) and (frames % 30 == 0) then + if view.credits == 0 then + tap(SWITCH_COIN1) + else + continues = continues + 1 + tap(SWITCH_START1) + end + end + return + end + -- The map: the trader once (with misses on), else the timer's own choice. + if view.mapWait then + if (not perfect) and (not traded) and (view.mapCursor == view.TRADER) then + traded = true + tap(SWITCH_BUTTON1) + end + return + end + -- The trader: a coin buys a cube. + if view.trader then + if (frames % 20 == 0) then + tap(SWITCH_COIN1) + end + return + end + -- The hellgate: one pull at the lever with misses on (a roll can take every life); the + -- perfect run lets the clip pass. + if view.hellgate then + if (not perfect) and not pulled then + pulled = true + tap(SWITCH_BUTTON1) + end + return + end + pulled = false + -- The board: one letter, and the rest to the clip's end. + if view.board then + if not lettered then + lettered = true + tap(SWITCH_BUTTON1) + end + return + end + -- A death: a cube against the first, with misses on. + if view.dying then + if (not perfect) and (not cubeUsed) and (view.cubes > 0) then + cubeUsed = true + tap(SWITCH_BUTTON2) + end + return + end + -- A level's window. + if view.window then + local moveKey = tostring(view.level) .. "/" .. tostring(view.seg) .. "/" .. tostring(view.move) + local key = moveKey .. "/" .. tostring(view.lives) + local retry = (tried[moveKey] ~= nil) + local at = view.frame + + if (at >= view.window[1] + 3) and (at <= view.window[2]) and not answered[key] then + local correct = view.correct + + if correct == nil then + -- The tutorial's choice: the sign post's right skips it, nothing plays it. + answered[key] = true + tried[moveKey] = true + if perfect then + tap(SWITCH_RIGHT) + end + return + end + if correct == view.NOMOVE then + -- A row that wants nothing. + answered[key] = true + return + end + local missed = (not perfect) and (view.move % 7 == 0) and (view.move % 5 ~= 0) and not retry + local wrong = (not perfect) and (view.move % 5 == 0) and not retry + + if missed then + answered[key] = true + tried[moveKey] = true + return + end + if (correct == view.TURNRIGHTSHOOT) or (correct == view.TURNLEFTSHOOT) then + -- A turn, then the shot two frames on. + if not view.turned then + if turnAt == nil then + turnAt = at + tap(wrong and SWITCH_UP or ((correct == view.TURNRIGHTSHOOT) and SWITCH_RIGHT or SWITCH_LEFT)) + if wrong then + answered[key] = true + tried[moveKey] = true + turnAt = nil + end + end + elseif at >= turnAt + 2 then + answered[key] = true + tried[moveKey] = true + turnAt = nil + tap(SWITCH_BUTTON1) + end + return + end + answered[key] = true + tried[moveKey] = true + local switch = SWITCH_OF[correct] + + if wrong then + switch = (correct == view.UP) and SWITCH_DOWN or SWITCH_UP + end + if switch then + tap(switch) + end + end + end +end diff --git a/testScripts/ports/timetravelerOriginal.singe b/testScripts/ports/timetravelerOriginal.singe new file mode 100644 index 000000000..69112de0a --- /dev/null +++ b/testScripts/ports/timetravelerOriginal.singe @@ -0,0 +1,66 @@ +-- Hologram Time Traveler as it ships, driven by timetravelerDrive.singe and traced. PORT_GAME +-- names the title's folder under the library and PORT_SCRIPT its script under it; run with -v +-- the title's video. +local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "") +local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest") +local game = os.getenv("PORT_GAME") or "HologramTimeTraveler" +local script = library .. "/" .. (os.getenv("PORT_SCRIPT") or (game .. "/Script/timetraveler.singe")) + +dofile(here .. "timetravelerDrive.singe") + +SINGE_LEGACY_SPRITE_ARGS = true +local realScriptPath = singeGetScriptPath +singeGetScriptPath = function() return script end +local realSkip = discSkipToFrame +discSkipToFrame = function(frame) + ttTraceSeek(frame) + return realSkip(frame) +end +local realSearch = discSearch +discSearch = function(frame) + ttTraceSeek(frame) + return realSearch(frame) +end +dofile(script) +singeGetScriptPath = realScriptPath + +local realUpdate = onOverlayUpdate + + +function ttPress(switch, down) + if down then + onInputPressed(switch) + else + onInputReleased(switch) + end +end + + +local function view() + local window, correct = nil, nil + + if (currentLevel == levelNormal) and (lvlState == lvlRunning) and move and move[currentMove] and (currentMove <= totalMoves) then + window = { move[currentMove][inputFrmStart], move[currentMove][inputFrmEnd] } + correct = move[currentMove][correctMove] + end + return { flow = gameflow, screen = currentLevel, step = lvlState, alt = altState, level = iCurPos, seg = segID, move = currentMove, moves = totalMoves, + frame = discGetFrame(), score = iScore, lives = iLives, cubes = iRevCubes, credits = iCredits, + window = window, correct = correct, turned = bTurn, + intro = (gameflow == flow_GameRunning) and (currentLevel == levelIntro), + dying = (currentLevel == levelNormal) and (lvlState == lvlPlayDeath), + mapWait = (currentLevel == levelMenuScreen) and (lvlState == lvlRunning), mapCursor = iCurPos, + trader = (currentLevel == levelTrader) and ((lvlState == lvlRunning) or (lvlState == branch01)), + hellgate = (currentLevel == levelHellgate) and (lvlState == branch01), + continuing = (currentLevel == levelContinue) and (lvlState == lvlRunning), + board = (currentLevel == levelHighScore) and (lvlState == lvlRunning), + UP = UP, DOWN = DOWN, LEFT = LEFT, RIGHT = RIGHT, ACTION = ACTION, NOMOVE = NOMOVE, TURNRIGHTSHOOT = TURNRIGHTSHOOT, TURNLEFTSHOOT = TURNLEFTSHOOT, TRADER = levelTrader } +end + + +onOverlayUpdate = function(...) + local result = realUpdate(...) + + ttDrive(view()) + + return result +end diff --git a/testScripts/ports/timetravelerPort.singe b/testScripts/ports/timetravelerPort.singe new file mode 100644 index 000000000..c715bedec --- /dev/null +++ b/testScripts/ports/timetravelerPort.singe @@ -0,0 +1,63 @@ +-- The Forge port of Hologram Time Traveler, driven and traced as the original is. +local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "") +local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest") +local game = os.getenv("PORT_GAME") or "HologramTimeTraveler" +local name = (os.getenv("PORT_SCRIPT") or (game .. "/Script/timetraveler.singe")):gsub("%.singe$", "") +local stem = name:match("([^/\\]+)$") + +dofile("Forge/AuthorCompile.singe") +dofile(here .. "timetravelerDrive.singe") + +local realSkip = discSkipToFrame +discSkipToFrame = function(frame) + ttTraceSeek(frame) + return realSkip(frame) +end +local realSearch = discSearch +discSearch = function(frame) + ttTraceSeek(frame) + return realSearch(frame) +end +dofile(authorBuild(library .. "/" .. name .. ".forge", singeGetDataPath() .. stem .. ".port.singe")) + +local realUpdate = onOverlayUpdate + + +function ttPress(switch, down) + if down then + authorSwitchDown(switch, nil) + else + authorSwitchUp(switch, nil) + end +end + + +local function view() + local q = authorEntity("game").q + local window, correct = nil, nil + + if (q.currentLevel == q.levelNormal) and (q.lvlState == q.lvlRunning) and q.move and q.move[q.currentMove] and (q.currentMove <= q.totalMoves) then + window = { q.move[q.currentMove][q.inputFrmStart], q.move[q.currentMove][q.inputFrmEnd] } + correct = q.move[q.currentMove][q.correctMove] + end + return { flow = q.gameflow, screen = q.currentLevel, step = q.lvlState, alt = q.altState, level = q.iCurPos, seg = q.segID, move = q.currentMove, moves = q.totalMoves, + frame = discGetFrame(), score = q.iScore, lives = q.iLives, cubes = q.iRevCubes, credits = q.iCredits, + window = window, correct = correct, turned = q.bTurn, + intro = (q.gameflow == q.flow_GameRunning) and (q.currentLevel == q.levelIntro), + dying = (q.currentLevel == q.levelNormal) and (q.lvlState == q.lvlPlayDeath), + mapWait = (q.currentLevel == q.levelMenuScreen) and (q.lvlState == q.lvlRunning), mapCursor = q.iCurPos, + trader = (q.currentLevel == q.levelTrader) and ((q.lvlState == q.lvlRunning) or (q.lvlState == q.branch01)), + hellgate = (q.currentLevel == q.levelHellgate) and (q.lvlState == q.branch01), + continuing = (q.currentLevel == q.levelContinue) and (q.lvlState == q.lvlRunning), + board = (q.currentLevel == q.levelHighScore) and (q.lvlState == q.lvlRunning), + UP = q.UP, DOWN = q.DOWN, LEFT = q.LEFT, RIGHT = q.RIGHT, ACTION = q.ACTION, NOMOVE = q.NOMOVE, TURNRIGHTSHOOT = q.TURNRIGHTSHOOT, TURNLEFTSHOOT = q.TURNLEFTSHOOT, TRADER = q.levelTrader } +end + + +onOverlayUpdate = function(...) + local result = realUpdate(...) + + ttDrive(view()) + + return result +end diff --git a/testScripts/ports/videolandDrive.singe b/testScripts/ports/videolandDrive.singe new file mode 100644 index 000000000..72d08cfab --- /dev/null +++ b/testScripts/ports/videolandDrive.singe @@ -0,0 +1,120 @@ +-- Drives Adventures in Videoland: Rollercoaster (the original or its port) from what the game +-- shows, the same way for both. The driver file defines what it takes: a view() of the game's +-- state and videolandKey(keysym, scancode) to press a key; this file decides. The trace lines +-- it prints are what compare.sh diffs. +-- +-- The policy: a name at the name prompt, any key where the game waits for one, and the +-- walkthrough's lines typed one character a frame whenever the game waits for a line; with +-- PORT_PERFECT=1 the walkthrough is the solution alone, otherwise it opens with the unknown +-- words, the inventory play, and two of the deaths (the restaurant's meal and the coaster's +-- car) before solving the game from the start again. + +VIDEOLAND_FRAMES = tonumber(os.getenv("PORT_FRAMES")) or 60000 + +local perfect = (os.getenv("PORT_PERFECT") == "1") + +-- The solution: the coins, the bear from the gallery, the uniform from the closet, the toolkit, +-- the book past the guard, the bear to the dancer for the ticket, the radio from the booth, the +-- batteries out of the bear, the jammer made and powered, and the tower. +local SOLUTION = { "E", "TAKE COINS", "W", "W", "S", "SHOOT", "N", "N", "E", "TAKE UNIFORM", "WEAR UNIFORM", "W", "S", "W", "TAKE TOOLKIT", + "E", "E", "S", "S", "TAKE BOOK", "READ BOOK", "N", "N", "E", "S", "YES", "S", "TAKE TICKET", "W", + "E", "E", "PLAY", "RADIO", "OPEN BEAR", "TAKE BATTERIES", "MAKE JAMMER", "PUT BATTERIES", "IN JAMMER", + "W", "W", "W", "W", "S", "S", "JAM" } + +-- The detours: words the game does not know, the inventory, a meal that ends the game and a +-- yes to play again, a walk to the top of the coaster and the car, and a yes again. +local DETOURS = { "DANCE", "HELP", "I", "LOOK", "TAKE ALL", "FIND BATTERIES", "KILL SABOTEUR", "N", "MAYBE", "YES", "HUH", "YES", + "E", "TAKE COINS", "DROP COINS", "TAKE COINS", "PUT COINS", "DOWN", "TAKE COINS", "GO NORTH", "W", "W", "N", + "EXAMINE BOX", "BREAK DOOR", "VISIT DANCER", "READ TICKET", "S", "W", "N", "N", "YES" } + +local lines = {} +local at = 1 +local typing = nil -- The line being typed, and how much of it is in. +local typed = 0 +local settle = 0 -- Frames to wait before the next line. +local frames = 0 +local pressedFor = nil -- The wait for any key already answered, by what follows it. +local lastLine = nil + +if not perfect then + for _, line in ipairs(DETOURS) do + lines[#lines + 1] = line + end +end +for _, line in ipairs(SOLUTION) do + lines[#lines + 1] = line +end +table.insert(lines, 1, "SCOTT") + +-- The states that wait for a typed line (the game's numbers): the name, the restaurant, the +-- tent, the parser, the play-again, the prize, and the put-where. +local WAITING = { [2] = true, [10] = true, [11] = true, [15] = true, [17] = true, [20] = true, [23] = true } + + +function videolandTraceSeek(frame) + debugPrint(string.format("TRACE seek %d", frame)) +end + + +-- view: { state, next, room, turns, keyboard, keyhit, delay, still, endFrame, frame, prints, +-- last (the newest line of the screen), input } +function videolandDrive(view) + frames = frames + 1 + local line = string.format("TRACE state %s next %s room %s turns %s kb %s hit %s delay %s still %s end %s prints %s last %s", + tostring(view.state), tostring(view.next), tostring(view.room), tostring(view.turns), tostring(view.keyboard), tostring(view.keyhit), + tostring(view.delay), tostring(view.still), tostring(view.endFrame), tostring(view.prints), tostring(view.last)) + + if line ~= lastLine then + debugPrint(line .. " disc " .. tostring(view.frame)) + lastLine = line + end + if frames >= VIDEOLAND_FRAMES then + debugPrint("TRACE end after " .. frames .. " frames") + singeQuit() + return + end + -- Any key where the game waits for one, once per wait (two waits follow each other at the + -- story's end, told apart by what comes next). A full stop: the keyboard is still on for + -- the frame the wait begins in, and a letter or a space pressed then would go into the line. + if view.keyhit then + if pressedFor ~= view.next then + pressedFor = view.next + videolandKey(46, SCANCODE.PERIOD.value) + end + return + end + pressedFor = nil + if typing then + -- The line in, a character a frame, then the return key. + typed = typed + 1 + if typed <= string.len(typing) then + local c = typing:sub(typed, typed) + + if c == " " then + videolandKey(32, SCANCODE.SPACE.value) + else + videolandKey(string.byte(string.lower(c)), SCANCODE[c].value) + end + else + videolandKey(13, SCANCODE.RETURN.value) + typing = nil + settle = 3 + end + return + end + if settle > 0 then + settle = settle - 1 + return + end + if os.getenv("PORT_DEBUG") == "1" and (frames % 100 == 0) then + debugPrint(string.format("DRIVE probe kb %s input [%s] next %s at %s line %s typing %s settle %s", tostring(view.keyboard), tostring(view.input), tostring(view.next), tostring(at), tostring(lines[at]), tostring(typing), tostring(settle))) + end + -- (Not while a delay runs: the keyboard is on through the bomb's, and a line typed then + -- is taken before the play-again prompt and leaves the game in no state at all.) + if view.keyboard and (view.delay == 0) and (view.input == "") and WAITING[view.next] and lines[at] then + typing = lines[at] + typed = 0 + at = at + 1 + debugPrint("DRIVE line " .. typing) + end +end diff --git a/testScripts/ports/videolandOriginal.singe b/testScripts/ports/videolandOriginal.singe new file mode 100644 index 000000000..dfeabe30d --- /dev/null +++ b/testScripts/ports/videolandOriginal.singe @@ -0,0 +1,58 @@ +-- Adventures in Videoland: Rollercoaster as it ships, driven by videolandDrive.singe and +-- traced. PORT_GAME names the title's folder under the library and PORT_SCRIPT its script +-- under it; run with -v the title's framefile. +local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "") +local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest") +local game = os.getenv("PORT_GAME") or "Rollercoaster" +local script = library .. "/" .. (os.getenv("PORT_SCRIPT") or (game .. "/" .. game .. ".singe")) + +dofile(here .. "videolandDrive.singe") + +SINGE_LEGACY_SPRITE_ARGS = true +-- The game seeds its random draws from os.time at load: pinned, as the port's is. +os.time = function(...) return 12345 end +local realScriptPath = singeGetScriptPath +singeGetScriptPath = function() return script end +local realSkip = discSkipToFrame +discSkipToFrame = function(frame) + videolandTraceSeek(frame) + return realSkip(frame) +end +local realSearch = discSearch +discSearch = function(frame) + videolandTraceSeek(frame) + return realSearch(frame) +end +dofile(script) +singeGetScriptPath = realScriptPath + +-- Every line the game prints, counted (the wrapped lines too). +local prints = 0 +local realPrint = screenPrint +screenPrint = function(what) + prints = prints + 1 + return realPrint(what) +end + +local realUpdate = onOverlayUpdate + + +-- A key, as the engine hands one to a script in the whole keyboard mode. +function videolandKey(keysym, scancode) + onKeyPressed(keysym, scancode) +end + + +local function view() + return { state = STATE, next = NEXT_STATE, room = L, turns = T, keyboard = (KEYBOARD_ON == ON), keyhit = (KEYHIT_ON == ON), delay = DELAY, + still = (SHOW_JPG ~= -1), endFrame = (END_FRAME ~= -1) and END_FRAME or nil, frame = discGetFrame(), prints = prints, last = SCREEN[SCREEN_LINES], input = INPUT } +end + + +onOverlayUpdate = function(...) + local result = realUpdate(...) + + videolandDrive(view()) + + return result +end diff --git a/testScripts/ports/videolandPort.singe b/testScripts/ports/videolandPort.singe new file mode 100644 index 000000000..62f90dac4 --- /dev/null +++ b/testScripts/ports/videolandPort.singe @@ -0,0 +1,57 @@ +-- The Forge port of Adventures in Videoland: Rollercoaster, driven and traced as the original +-- is. +local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "") +local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest") +local game = os.getenv("PORT_GAME") or "Rollercoaster" +local name = (os.getenv("PORT_SCRIPT") or (game .. "/" .. game .. ".singe")):gsub("%.singe$", "") +local stem = name:match("([^/\\]+)$") + +dofile("Forge/AuthorCompile.singe") +dofile(here .. "videolandDrive.singe") + +-- The game seeds its random draws from os.time at load; the original's driver pins it, and so +-- does this, to the same number. +os.time = function(...) return 12345 end + +local realSkip = discSkipToFrame +discSkipToFrame = function(frame) + videolandTraceSeek(frame) + return realSkip(frame) +end +local realSearch = discSearch +discSearch = function(frame) + videolandTraceSeek(frame) + return realSearch(frame) +end +dofile(authorBuild(library .. "/" .. name .. ".forge", singeGetDataPath() .. stem .. ".port.singe")) + +local realUpdate = onOverlayUpdate + + +-- A key, as the engine hands one to the runtime in the whole keyboard mode: its keysym on the +-- switch channel, then the key itself. The return and backspace keys' keysyms are switch +-- numbers too, and the runtime drops those while the key is down, which a driven run cannot +-- show it; they go by the key alone, as they arrive in play. +function videolandKey(keysym, scancode) + if (keysym ~= 13) and (keysym ~= 8) then + authorSwitchDown(keysym, nil) + end + authorKeyDown(keysym, scancode) +end + + +local function view() + local q = authorEntity("game").q + + return { state = q.state, next = q.next, room = q.L, turns = q.T, keyboard = q.keyboard, keyhit = q.keyhit, delay = q.delay, + still = (q.still ~= nil), endFrame = q.endFrame, frame = discGetFrame(), prints = q.prints, last = q.screen[q.lines], input = q.input } +end + + +onOverlayUpdate = function(...) + local result = realUpdate(...) + + videolandDrive(view()) + + return result +end diff --git a/testScripts/scene52.singe b/testScripts/scene52.singe index 6469b7d1f..4ad493c09 100644 --- a/testScripts/scene52.singe +++ b/testScripts/scene52.singe @@ -15,10 +15,10 @@ fontQuality(FONT_QUALITY_BLENDED) overlaySetResolution(720, 480) for _, name in ipairs({ "platformer", "qte", "shmup", "gunvideo" }) do - local game = authorLoad("testScripts/author/" .. name .. ".game") + local game = authorLoad("testScripts/author/" .. name .. ".forge") local problems = authorCheck(game) - built[name] = authorBuild("testScripts/author/" .. name .. ".game", out .. name .. ".singe") + built[name] = authorBuild("testScripts/author/" .. name .. ".forge", out .. name .. ".singe") debugPrint("AUTHOR built " .. built[name] .. " with " .. #problems .. " problems") for _, problem in ipairs(problems) do debugPrint("AUTHOR FAIL " .. name .. ": " .. problem) diff --git a/testScripts/scene53.singe b/testScripts/scene53.singe index b08668b3b..af9c7ef7a 100644 --- a/testScripts/scene53.singe +++ b/testScripts/scene53.singe @@ -10,7 +10,7 @@ local font = fontLoad("Singe/FreeSansBold.ttf", 18) fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) -dofile(authorBuild("testScripts/author/qte.game", singeGetDataPath() .. "qte.singe")) +dofile(authorBuild("testScripts/author/qte.forge", singeGetDataPath() .. "qte.singe")) local gameUpdate = onOverlayUpdate local frames = 0 diff --git a/testScripts/scene54.singe b/testScripts/scene54.singe index ff4b7db44..436be9061 100644 --- a/testScripts/scene54.singe +++ b/testScripts/scene54.singe @@ -12,7 +12,7 @@ dofile("Forge/Forge.singe") local font = fontLoad("Singe/FreeSansBold.ttf", 16) local out = singeGetDataPath() -local work = out .. "edited.game" +local work = out .. "edited.forge" fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) @@ -20,7 +20,7 @@ overlaySetResolution(720, 480) -- Work on a copy; an editor that writes back to the file it was handed is right, but a test that -- edits the repository's own sample is not. -local source = assert(io.open("testScripts/author/platformer.game", "r")) +local source = assert(io.open("testScripts/author/platformer.forge", "r")) local copy = assert(io.open(work, "w")) copy:write(source:read("a")) @@ -94,8 +94,8 @@ local function step() -- The invariant the whole editor rests on: a description that has been through load and -- save has to compile to the same game as the one that has not. Without it, opening a -- game in the editor and saving it unchanged could quietly alter it. - local original = authorLoad("testScripts/author/platformer.game") - local trip = out .. "trip.game" + local original = authorLoad("testScripts/author/platformer.forge") + local trip = out .. "trip.forge" authorSave(original, trip) local again = authorLoad(trip) diff --git a/testScripts/scene55.singe b/testScripts/scene55.singe index 24e2aaa5e..6ea271ced 100644 --- a/testScripts/scene55.singe +++ b/testScripts/scene55.singe @@ -10,13 +10,13 @@ dofile("Forge/Forge.singe") local font = fontLoad("Singe/FreeSansBold.ttf", 15) local out = singeGetDataPath() -local work = out .. "rules.game" +local work = out .. "rules.forge" fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) overlaySetResolution(720, 480) -local source = assert(io.open("testScripts/author/platformer.game", "r")) +local source = assert(io.open("testScripts/author/platformer.forge", "r")) local copy = assert(io.open(work, "w")) copy:write(source:read("a")) diff --git a/testScripts/scene56.singe b/testScripts/scene56.singe index 8ec459c3a..b00b3fc9b 100644 --- a/testScripts/scene56.singe +++ b/testScripts/scene56.singe @@ -9,14 +9,14 @@ dofile("Forge/Forge.singe") local font = fontLoad("Singe/FreeSansBold.ttf", 15) local out = singeGetDataPath() -local work = out .. "release.game" +local work = out .. "release.forge" local dest = out .. "Released" fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) overlaySetResolution(720, 480) -local source = assert(io.open("testScripts/author/platformer.game", "r")) +local source = assert(io.open("testScripts/author/platformer.forge", "r")) local copy = assert(io.open(work, "w")) copy:write(source:read("a")) @@ -40,7 +40,7 @@ local function step() elseif stage == 2 then -- Everything a standalone game needs has to be there. - for _, name in ipairs({ "Platformer.singe", "Author.singe", "games.dat", "Platformer.game" }) do + for _, name in ipairs({ "Platformer.singe", "Author.singe", "games.dat", "Platformer.forge" }) do local f = io.open(dest .. "/" .. name, "r") if f == nil then diff --git a/testScripts/scene58.singe b/testScripts/scene58.singe index 14e85d514..1f14e8bc1 100644 --- a/testScripts/scene58.singe +++ b/testScripts/scene58.singe @@ -9,7 +9,7 @@ fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) overlaySetResolution(720, 480) -dofile(authorBuild("testScripts/author/shmup.game", singeGetDataPath() .. "shmup.singe")) +dofile(authorBuild("testScripts/author/shmup.forge", singeGetDataPath() .. "shmup.singe")) local gameUpdate = onOverlayUpdate local frames = 0 diff --git a/testScripts/scene59.singe b/testScripts/scene59.singe index c4d7bf6b8..8b87931ee 100644 --- a/testScripts/scene59.singe +++ b/testScripts/scene59.singe @@ -9,7 +9,7 @@ local font = fontLoad("Singe/FreeSansBold.ttf", 18) fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) -dofile(authorBuild("testScripts/author/gunvideo.game", singeGetDataPath() .. "gunvideo.singe")) +dofile(authorBuild("testScripts/author/gunvideo.forge", singeGetDataPath() .. "gunvideo.singe")) local gameUpdate = onOverlayUpdate local frames = 0 diff --git a/testScripts/scene60.singe b/testScripts/scene60.singe index e3ce911b8..2cbbe5e0a 100644 --- a/testScripts/scene60.singe +++ b/testScripts/scene60.singe @@ -7,13 +7,13 @@ dofile("Forge/Forge.singe") local font = fontLoad("Singe/FreeSansBold.ttf", 15) local out = singeGetDataPath() -local work = out .. "timeline.game" +local work = out .. "timeline.forge" fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) overlaySetResolution(720, 480) -local source = assert(io.open("testScripts/author/gunvideo.game", "r")) +local source = assert(io.open("testScripts/author/gunvideo.forge", "r")) local copy = assert(io.open(work, "w")) copy:write(source:read("a")) diff --git a/testScripts/scene61.singe b/testScripts/scene61.singe index c0747cabf..698ec8b3f 100644 --- a/testScripts/scene61.singe +++ b/testScripts/scene61.singe @@ -9,7 +9,7 @@ local font = fontLoad("Singe/FreeSansBold.ttf", 18) fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) -dofile(authorBuild("testScripts/author/railshooter.game", singeGetDataPath() .. "railshooter.singe")) +dofile(authorBuild("testScripts/author/railshooter.forge", singeGetDataPath() .. "railshooter.singe")) local gameUpdate = onOverlayUpdate local frames = 0 diff --git a/testScripts/scene62.singe b/testScripts/scene62.singe index 0eba7c5df..fe9b9b46a 100644 --- a/testScripts/scene62.singe +++ b/testScripts/scene62.singe @@ -8,7 +8,7 @@ local font = fontLoad("Singe/FreeSansBold.ttf", 18) fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) -dofile(authorBuild("testScripts/author/platformer3d.game", singeGetDataPath() .. "platformer3d.singe")) +dofile(authorBuild("testScripts/author/platformer3d.forge", singeGetDataPath() .. "platformer3d.singe")) local gameUpdate = onOverlayUpdate local frames = 0 diff --git a/testScripts/scene63.singe b/testScripts/scene63.singe index 6dc5dfdd4..9aff325d1 100644 --- a/testScripts/scene63.singe +++ b/testScripts/scene63.singe @@ -7,13 +7,13 @@ dofile("Forge/Forge.singe") local font = fontLoad("Singe/FreeSansBold.ttf", 15) local out = singeGetDataPath() -local work = out .. "rail.game" +local work = out .. "rail.forge" fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) overlaySetResolution(720, 480) -local source = assert(io.open("testScripts/author/railshooter.game", "r")) +local source = assert(io.open("testScripts/author/railshooter.forge", "r")) local copy = assert(io.open(work, "w")) copy:write(source:read("a")) diff --git a/testScripts/scene64.singe b/testScripts/scene64.singe index 9ddc950aa..f28bedef8 100644 --- a/testScripts/scene64.singe +++ b/testScripts/scene64.singe @@ -9,7 +9,7 @@ local font = fontLoad("Singe/FreeSansBold.ttf", 16) fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) -dofile(authorBuild("testScripts/author/adventure2d.game", singeGetDataPath() .. "adventure2d.singe")) +dofile(authorBuild("testScripts/author/adventure2d.forge", singeGetDataPath() .. "adventure2d.singe")) local gameUpdate = onOverlayUpdate local frames = 0 diff --git a/testScripts/scene65.singe b/testScripts/scene65.singe index e07d999cd..caa486588 100644 --- a/testScripts/scene65.singe +++ b/testScripts/scene65.singe @@ -9,7 +9,7 @@ local font = fontLoad("Singe/FreeSansBold.ttf", 16) fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) -dofile(authorBuild("testScripts/author/adventure3d.game", singeGetDataPath() .. "adventure3d.singe")) +dofile(authorBuild("testScripts/author/adventure3d.forge", singeGetDataPath() .. "adventure3d.singe")) local gameUpdate = onOverlayUpdate local frames = 0 diff --git a/testScripts/scene66.singe b/testScripts/scene66.singe index f3ab1ef40..f89d6fdab 100644 --- a/testScripts/scene66.singe +++ b/testScripts/scene66.singe @@ -7,7 +7,7 @@ local font = fontLoad("Singe/FreeSansBold.ttf", 18) fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) -dofile(authorBuild("testScripts/author/racing.game", singeGetDataPath() .. "racing.singe")) +dofile(authorBuild("testScripts/author/racing.forge", singeGetDataPath() .. "racing.singe")) local gameUpdate = onOverlayUpdate local frames = 0 diff --git a/testScripts/scene67.singe b/testScripts/scene67.singe index 24aba0f59..29c650cc5 100644 --- a/testScripts/scene67.singe +++ b/testScripts/scene67.singe @@ -8,7 +8,7 @@ local font = fontLoad("Singe/FreeSansBold.ttf", 18) fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) -dofile(authorBuild("testScripts/author/towerdefence.game", singeGetDataPath() .. "towerdefence.singe")) +dofile(authorBuild("testScripts/author/towerdefence.forge", singeGetDataPath() .. "towerdefence.singe")) local gameUpdate = onOverlayUpdate local frames = 0 diff --git a/testScripts/scene68.singe b/testScripts/scene68.singe index 820b02c9f..75293f3fd 100644 --- a/testScripts/scene68.singe +++ b/testScripts/scene68.singe @@ -15,7 +15,7 @@ function masterSubmitScore(gameId, value, board, meta) end function masterPump() end -dofile(authorBuild("testScripts/author/fmv.game", singeGetDataPath() .. "fmv.singe")) +dofile(authorBuild("testScripts/author/fmv.forge", singeGetDataPath() .. "fmv.singe")) local gameUpdate = onOverlayUpdate local frames = 0 diff --git a/testScripts/scene69.singe b/testScripts/scene69.singe index e0e29c1e5..1119df30b 100644 --- a/testScripts/scene69.singe +++ b/testScripts/scene69.singe @@ -6,13 +6,13 @@ dofile("Forge/Forge.singe") local font = fontLoad("Singe/FreeSansBold.ttf", 15) local out = singeGetDataPath() -local work = out .. "adv.game" +local work = out .. "adv.forge" fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) overlaySetResolution(720, 480) -local source = assert(io.open("testScripts/author/adventure2d.game", "r")) +local source = assert(io.open("testScripts/author/adventure2d.forge", "r")) local copy = assert(io.open(work, "w")) copy:write(source:read("a")) diff --git a/testScripts/scene70.singe b/testScripts/scene70.singe index 12f22b6b7..703d5d659 100644 --- a/testScripts/scene70.singe +++ b/testScripts/scene70.singe @@ -7,7 +7,7 @@ local font = fontLoad("Singe/FreeSansBold.ttf", 18) fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) -dofile(authorBuild("testScripts/author/tail.game", singeGetDataPath() .. "tail.singe")) +dofile(authorBuild("testScripts/author/tail.forge", singeGetDataPath() .. "tail.singe")) local gameUpdate = onOverlayUpdate local frames = 0 diff --git a/testScripts/scene71.singe b/testScripts/scene71.singe index 2ec93218b..2273f7c4f 100644 --- a/testScripts/scene71.singe +++ b/testScripts/scene71.singe @@ -7,7 +7,7 @@ local font = fontLoad("Singe/FreeSansBold.ttf", 18) fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) -dofile(authorBuild("testScripts/author/pool.game", singeGetDataPath() .. "pool.singe")) +dofile(authorBuild("testScripts/author/pool.forge", singeGetDataPath() .. "pool.singe")) local gameUpdate = onOverlayUpdate local frames = 0 diff --git a/testScripts/scene72.singe b/testScripts/scene72.singe index f2b77249e..81e49f41b 100644 --- a/testScripts/scene72.singe +++ b/testScripts/scene72.singe @@ -9,7 +9,7 @@ local font = fontLoad("Singe/FreeSansBold.ttf", 18) fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) -dofile(authorBuild("testScripts/author/fps.game", singeGetDataPath() .. "fps.singe")) +dofile(authorBuild("testScripts/author/fps.forge", singeGetDataPath() .. "fps.singe")) local gameUpdate = onOverlayUpdate local frames = 0 diff --git a/testScripts/scene73.singe b/testScripts/scene73.singe index 5340cb217..af9089a06 100644 --- a/testScripts/scene73.singe +++ b/testScripts/scene73.singe @@ -8,7 +8,7 @@ fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) overlaySetResolution(720, 480) -dofile(authorBuild("testScripts/author/breakout.game", singeGetDataPath() .. "breakout.singe")) +dofile(authorBuild("testScripts/author/breakout.forge", singeGetDataPath() .. "breakout.singe")) local gameUpdate = onOverlayUpdate local frames = 0 diff --git a/testScripts/scene74.singe b/testScripts/scene74.singe index cb10600a7..1ce3632fc 100644 --- a/testScripts/scene74.singe +++ b/testScripts/scene74.singe @@ -8,7 +8,7 @@ fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) overlaySetResolution(720, 480) -dofile(authorBuild("testScripts/author/maze.game", singeGetDataPath() .. "maze.singe")) +dofile(authorBuild("testScripts/author/maze.forge", singeGetDataPath() .. "maze.singe")) local gameUpdate = onOverlayUpdate local frames = 0 diff --git a/testScripts/scene75.singe b/testScripts/scene75.singe index 0ba9e6049..01531aab2 100644 --- a/testScripts/scene75.singe +++ b/testScripts/scene75.singe @@ -8,7 +8,7 @@ local font = fontLoad("Singe/FreeSansBold.ttf", 18) fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) -dofile(authorBuild("testScripts/author/thirdperson.game", singeGetDataPath() .. "thirdperson.singe")) +dofile(authorBuild("testScripts/author/thirdperson.forge", singeGetDataPath() .. "thirdperson.singe")) local gameUpdate = onOverlayUpdate local frames = 0 diff --git a/testScripts/scene76.singe b/testScripts/scene76.singe index f446683b8..94152d9a5 100644 --- a/testScripts/scene76.singe +++ b/testScripts/scene76.singe @@ -7,7 +7,7 @@ local font = fontLoad("Singe/FreeSansBold.ttf", 18) fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) -dofile(authorBuild("testScripts/author/space.game", singeGetDataPath() .. "space.singe")) +dofile(authorBuild("testScripts/author/space.forge", singeGetDataPath() .. "space.singe")) local gameUpdate = onOverlayUpdate local frames = 0 diff --git a/testScripts/scene77.singe b/testScripts/scene77.singe index 11620d50b..ff7f40336 100644 --- a/testScripts/scene77.singe +++ b/testScripts/scene77.singe @@ -8,7 +8,7 @@ fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) overlaySetResolution(720, 480) -dofile(authorBuild("testScripts/author/rhythm.game", singeGetDataPath() .. "rhythm.singe")) +dofile(authorBuild("testScripts/author/rhythm.forge", singeGetDataPath() .. "rhythm.singe")) local gameUpdate = onOverlayUpdate local frames = 0 diff --git a/testScripts/scene78.singe b/testScripts/scene78.singe index 82f425894..bb8abbda7 100644 --- a/testScripts/scene78.singe +++ b/testScripts/scene78.singe @@ -7,7 +7,7 @@ local font = fontLoad("Singe/FreeSansBold.ttf", 18) fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) -dofile(authorBuild("testScripts/author/bowling.game", singeGetDataPath() .. "bowling.singe")) +dofile(authorBuild("testScripts/author/bowling.forge", singeGetDataPath() .. "bowling.singe")) local gameUpdate = onOverlayUpdate local frames = 0 diff --git a/testScripts/scene79.singe b/testScripts/scene79.singe index cde0bec12..372578c09 100644 --- a/testScripts/scene79.singe +++ b/testScripts/scene79.singe @@ -8,7 +8,7 @@ local font = fontLoad("Singe/FreeSansBold.ttf", 18) fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) -dofile(authorBuild("testScripts/author/painted.game", singeGetDataPath() .. "painted.singe")) +dofile(authorBuild("testScripts/author/painted.forge", singeGetDataPath() .. "painted.singe")) local gameUpdate = onOverlayUpdate local frames = 0 diff --git a/testScripts/scene80.singe b/testScripts/scene80.singe index c41d31ded..8dafff4e4 100644 --- a/testScripts/scene80.singe +++ b/testScripts/scene80.singe @@ -6,13 +6,13 @@ dofile("Forge/Forge.singe") local font = fontLoad("Singe/FreeSansBold.ttf", 15) local out = singeGetDataPath() -local work = out .. "gizmo.game" +local work = out .. "gizmo.forge" fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) overlaySetResolution(720, 480) -local source = assert(io.open("testScripts/author/railshooter.game", "r")) +local source = assert(io.open("testScripts/author/railshooter.forge", "r")) local copy = assert(io.open(work, "w")) copy:write(source:read("a")) diff --git a/testScripts/scene81.singe b/testScripts/scene81.singe index d2c1ba624..a6f349a56 100644 --- a/testScripts/scene81.singe +++ b/testScripts/scene81.singe @@ -9,7 +9,7 @@ local font = fontLoad("Singe/FreeSansBold.ttf", 18) fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) -dofile(authorBuild("testScripts/author/tail.game", singeGetDataPath() .. "facing.singe")) +dofile(authorBuild("testScripts/author/tail.forge", singeGetDataPath() .. "facing.singe")) local gameUpdate = onOverlayUpdate local frames = 0 diff --git a/testScripts/scene82.singe b/testScripts/scene82.singe index b4b66fbca..14acba5d6 100644 --- a/testScripts/scene82.singe +++ b/testScripts/scene82.singe @@ -6,13 +6,13 @@ dofile("Forge/Forge.singe") local font = fontLoad("Singe/FreeSansBold.ttf", 15) local out = singeGetDataPath() -local work = out .. "turn.game" +local work = out .. "turn.forge" fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) overlaySetResolution(720, 480) -local source = assert(io.open("testScripts/author/tail.game", "r")) +local source = assert(io.open("testScripts/author/tail.forge", "r")) local copy = assert(io.open(work, "w")) copy:write(source:read("a")) diff --git a/testScripts/scene83.singe b/testScripts/scene83.singe index 34811f41d..2bcc15487 100644 --- a/testScripts/scene83.singe +++ b/testScripts/scene83.singe @@ -1,6 +1,6 @@ -- Forge tutorial 1, "Ten minutes": the chooser, a new game, the four panels, one typed change, -- save, and play. The pictures in docs/Forge.adoc come from here, and the description the keys --- make is checked against the one the book ships (Forge/tutorials/01-tenMinutes.game). +-- make is checked against the one the book ships (Forge/tutorials/01-tenMinutes.forge). TUTORIAL = { number = 1, name = "01-tenMinutes", tag = "TUTORIAL1" } dofile("testScripts/tutorialDrive.singe") @@ -26,7 +26,7 @@ local function step() end -- The scene keeps its own copy under the tutorial's name, so the check at the end can find it. forgeSave() - authorCopy(FORGE.path, TUTORIAL_OUT .. TUTORIAL.name .. ".game") + authorCopy(FORGE.path, TUTORIAL_OUT .. TUTORIAL.name .. ".forge") forgeClose() tutorialBegin(nil) tutorialShot("entities") diff --git a/testScripts/scene92.singe b/testScripts/scene92.singe index d135f8698..8b327c5fe 100644 --- a/testScripts/scene92.singe +++ b/testScripts/scene92.singe @@ -98,7 +98,7 @@ local function step() end table.sort(files) debugPrint(TUTORIAL.tag .. " released to " .. folder .. ": " .. table.concat(files, ", ")) - for _, want in ipairs({ "CoinRun.singe", "CoinRun.game", "Author.singe", "games.dat", "Forge" }) do + for _, want in ipairs({ "CoinRun.singe", "CoinRun.forge", "Author.singe", "games.dat", "Forge" }) do if lfs.attributes(folder .. "/" .. want) == nil then debugPrint(TUTORIAL.tag .. " FAIL the release lacks " .. want) end @@ -114,7 +114,7 @@ local function step() local bin = os.getenv("SINGE_BIN") if bin then - local packed = TUTORIAL_OUT .. "CoinRun.game" + local packed = TUTORIAL_OUT .. "CoinRun.forge" os.remove(packed) os.execute(string.format('"%s" --pack "%s" "%s" > "%s" 2>&1', bin, folder, packed, TUTORIAL_OUT .. "pack.log")) diff --git a/testScripts/scene94.singe b/testScripts/scene94.singe index da1be66b2..31f13fa76 100644 --- a/testScripts/scene94.singe +++ b/testScripts/scene94.singe @@ -8,7 +8,7 @@ local font = fontLoad("Singe/FreeSansBold.ttf", 18) fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) -dofile(authorBuild("testScripts/author/waves.game", singeGetDataPath() .. "waves.singe")) +dofile(authorBuild("testScripts/author/waves.forge", singeGetDataPath() .. "waves.singe")) local gameUpdate = onOverlayUpdate local shot = false diff --git a/testScripts/scene96.singe b/testScripts/scene96.singe new file mode 100644 index 000000000..07c344318 --- /dev/null +++ b/testScripts/scene96.singe @@ -0,0 +1,62 @@ +-- Forge: the overlay bits the ActionMax port needed -- a text look's font, size, and anchors; +-- a sprite in copies; a pointer-following crosshair; discPause and discPlay; pointerX. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +dofile(authorBuild("testScripts/author/overlayBits.forge", singeGetDataPath() .. "overlayBits.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local heldAt = nil +local heldFrames = 0 + + +function onOverlayUpdate() + frames = frames + 1 + if frames == 5 then + authorSetPointer(1, 100, 200, true) + elseif frames == 20 then + authorSetPointer(1, 600, 200, true) + elseif frames == 30 then + AUTHOR_VARS.coins = 5 + authorKeyDown(0, SCANCODE.SPACE.value) + elseif (frames > 32) and (frames < 60) then + heldAt = heldAt or discGetFrame() + if discGetFrame() == heldAt then + heldFrames = heldFrames + 1 + end + elseif frames == 60 then + authorKeyDown(0, SCANCODE.RETURN.value) + elseif frames == 62 then + singeScreenshot() + end + local r = gameUpdate() + + if frames == 10 then + local cross = authorEntity("cross") + local cx, cy = authorPosition(cross) + + if (AUTHOR_VARS.side ~= "left") or (cross.vars.text ~= nil) then debugPrint("BITS FAIL the pointer on the left was not seen: " .. tostring(AUTHOR_VARS.side)) end + if (math.abs(cx - 100) > 1) or (math.abs(cy - 200) > 1) then debugPrint(string.format("BITS FAIL the crosshair is at %.0f,%.0f, not on the pointer", cx, cy)) end + elseif frames == 25 then + if AUTHOR_VARS.side ~= "right" then debugPrint("BITS FAIL the pointer on the right was not seen") end + elseif frames == 90 then + local coins = authorEntity("coins") + local mid = authorEntity("mid") + local w, h = authorSize(authorEntity("title")) + + debugPrint(string.format("BITS RESULT side=%s copies=%s held=%d frames at %s, now %d, title %dx%d, mid says %s", AUTHOR_VARS.side, tostring(coins.vars.copies), heldFrames, tostring(heldAt), discGetFrame(), w, h, mid.vars.text)) + if coins.vars.copies ~= 5 then debugPrint("BITS FAIL the coins did not follow the var") end + if heldFrames < 20 then debugPrint("BITS FAIL discPause did not hold the disc") end + if discGetFrame() <= (heldAt or 0) then debugPrint("BITS FAIL discPlay did not let the disc go") end + if h < 30 then debugPrint("BITS FAIL the title's own font was not used (36 points)") end + if mid.vars.text ~= "pointer on the right" then debugPrint("BITS FAIL the centred text did not say where the pointer is") end + singeQuit() + end + + return r +end diff --git a/testScripts/scene97.singe b/testScripts/scene97.singe new file mode 100644 index 000000000..22539d836 --- /dev/null +++ b/testScripts/scene97.singe @@ -0,0 +1,85 @@ +-- Forge: a game's own vocabulary. The description names a file of looks, behaviours, +-- conditions, and actions that this game alone knows; the editor offers them while the game is +-- open and forgets them when it closes; the compiler checks against them; the built game +-- loads them and plays by them. +FORGE_LIBRARY = true +dofile("Forge/Forge.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +local work = singeGetDataPath() .. "vocab.forge" + +-- The editor: the vocabulary is there while the game is open, and gone when it is closed. +authorCopy("testScripts/author/vocab.forge", work) +authorCopy("testScripts/author/vocab.singe", singeGetDataPath() .. "vocab.singe") +if not forgeBegin(work) then + debugPrint("VOCAB FAIL the editor could not open the game") +end +if AUTHOR.behaviours.bob and AUTHOR.conditions.bobbing and AUTHOR.actions.setBob then + debugPrint("VOCAB the editor knows the game's own bob, bobbing, and setBob") +else + debugPrint("VOCAB FAIL the editor did not load the game's vocabulary") +end +local built = forgeBuild(singeGetDataPath() .. "vocabGame.singe") +local file = built and io.open(singeGetDataPath() .. "vocabGame.singe", "r") +local text = file and file:read("a") or "" + +if file then + file:close() +end +if text:find('authorVocabulary("vocab.singe")', 1, true) then + debugPrint("VOCAB the built game loads the vocabulary") +else + debugPrint("VOCAB FAIL the built game does not name the vocabulary") +end +forgeClose() +if AUTHOR.behaviours.bob or AUTHOR.conditions.bobbing or AUTHOR.actions.setBob then + debugPrint("VOCAB FAIL closing the game did not forget its vocabulary") +else + debugPrint("VOCAB closing the game forgot its vocabulary") +end + +-- The game: built and played, the buoy bobs and the rules use the game's own words. +dofile(singeGetDataPath() .. "vocabGame.singe") + +local gameUpdate = onOverlayUpdate +local frames = 0 +local lowest = 1000 +local highest = 0 +local lowest2 = 1000 +local highest2 = 0 + + +function onOverlayUpdate() + frames = frames + 1 + local r = gameUpdate() + local buoy = authorEntity("buoy") + local rock = authorEntity("rock") + local _, by = authorPosition(buoy) + local _, ry = authorPosition(rock) + + if frames <= 90 then + lowest = math.min(lowest, by) + highest = math.max(highest, by) + elseif frames > 92 then + lowest2 = math.min(lowest2, by) + highest2 = math.max(highest2, by) + end + if frames == 91 then + authorKeyDown(0, SCANCODE.SPACE.value) + elseif frames == 60 then + singeScreenshot() + elseif frames == 180 then + debugPrint(string.format("VOCAB RESULT buoy %.0f..%.0f then %.0f..%.0f, rock at %.0f, seen %d frames bobbing", lowest, highest, lowest2, highest2, ry, AUTHOR_VARS.seen)) + if (highest - lowest) < 60 or (highest - lowest) > 82 then debugPrint("VOCAB FAIL the buoy did not bob 40 either way") end + if (highest2 - lowest2) < 150 then debugPrint("VOCAB FAIL setBob did not raise the bob to 100") end + if math.abs(ry - 240) > 1 then debugPrint("VOCAB FAIL the rock moved") end + if AUTHOR_VARS.seen < 170 then debugPrint("VOCAB FAIL the bobbing condition was not true every frame") end + singeQuit() + end + + return r +end diff --git a/testScripts/scene98.singe b/testScripts/scene98.singe new file mode 100644 index 000000000..dfbe7da9b --- /dev/null +++ b/testScripts/scene98.singe @@ -0,0 +1,56 @@ +-- Forge: mash and hold as native kinds of a branching track's move. The first window is +-- mashed six times and taken; the second is held long enough and taken; the third is mashed +-- only three times and missed. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +dofile(authorBuild("testScripts/author/mash.forge", singeGetDataPath() .. "mash.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local taps = 0 +local held = false + + +function onOverlayUpdate() + local disc = discGetFrame() + + frames = frames + 1 + -- Six taps in the first window, three in the third; a tap is a press one frame and a + -- release the next. + if ((disc >= 125) and (disc < 150) and (taps < 6)) or ((disc >= 285) and (disc < 305) and (taps >= 6) and (taps < 9)) then + if frames % 2 == 0 then + authorKeyDown(0, SCANCODE.RIGHT.value) + taps = taps + 1 + else + authorKeyUp(0, SCANCODE.RIGHT.value) + end + end + -- UP held through the second window. + if (disc >= 205) and (disc < 250) and not held then + authorKeyDown(0, SCANCODE.UP.value) + held = true + elseif (disc >= 250) and held then + authorKeyUp(0, SCANCODE.UP.value) + held = false + end + local r = gameUpdate() + + if disc == 140 then + singeScreenshot() + end + -- The stand-in video loops at its end; the run ends before it does. + if (disc >= 370) or (frames > 1200) then + debugPrint(string.format("MASH RESULT taken=%d missed=%d opened=%s", AUTHOR_VARS.taken, AUTHOR_VARS.missed, AUTHOR_VARS.opened)) + if AUTHOR_VARS.taken ~= 2 then debugPrint("MASH FAIL the mashed and held windows were not both taken") end + if AUTHOR_VARS.missed ~= 1 then debugPrint("MASH FAIL the under-mashed window was not missed") end + if AUTHOR_VARS.opened ~= "RIGHT UP RIGHT " then debugPrint("MASH FAIL the windows did not open in order") end + singeQuit() + end + + return r +end diff --git a/testScripts/scene99.singe b/testScripts/scene99.singe new file mode 100644 index 000000000..788fa0c47 --- /dev/null +++ b/testScripts/scene99.singe @@ -0,0 +1,63 @@ +-- Formats the build reads but no scene had exercised: a JPEG XL picture beside the PNG it was +-- made from (cjxl -d 0, lossless, so the two must match pixel for pixel), and the chiptune and +-- tracker decoders -- game-music-emu's NSF, libxmp's XM, IT and S3M -- each played as music for +-- a while, the NSF as a sound effect as well. The log says what played; the screenshot shows +-- the two crates. +dofile("Singe/Framework.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 14) +local png = spriteLoad("testScripts/crate.png") +local jxl = spriteLoad("testScripts/crate.jxl") +local tunes = { "testScripts/chip.nsf", "testScripts/tracker.xm", "testScripts/tracker.it", "testScripts/tracker.s3m" } +local effect = soundLoad("testScripts/chip.nsf") +local frames = 0 +local at = 0 +local music = nil +local said = "none yet" +local TUNE_FRAMES = 60 + +local pngBig = spriteLoad("testScripts/crate.png") +local jxlBig = spriteLoad("testScripts/crate.jxl") + +fontSelect(font) +overlaySetResolution(1024, 576) -- The picture's own size on the offscreen desktop (16:9 in 1024x768), so a screenshot is the overlay pixel for pixel, 96 rows down. +spriteScale(pngBig, 4) +spriteScale(jxlBig, 4) +discPlay() + + +function onOverlayUpdate() + frames = frames + 1 + overlayClear() + spriteDraw(png, 40, 60) + spriteDraw(jxl, 260, 60) + spriteDraw(pngBig, 40, 140) + spriteDraw(jxlBig, 260, 140) + fontPrint(8, 6, "crate.png (left) and crate.jxl (right), lossless: the same picture twice, as loaded and at four times") + fontPrint(8, 24, string.format("music %s playing %s", said, tostring(musicIsPlaying()))) + if frames == 10 then + singeScreenshot("formats") + end + if frames == 30 then + soundPlay(effect) + debugPrint("FORMATS sound chip.nsf played") + end + if (frames % TUNE_FRAMES) == 40 then + if music ~= nil then + debugPrint(string.format("FORMATS music %s was playing %s", said, tostring(musicIsPlaying()))) + musicStop() + musicUnload(music) + music = nil + end + at = at + 1 + if tunes[at] ~= nil then + music = musicLoad(tunes[at]) + musicPlay(music, -1) + said = tunes[at] + debugPrint("FORMATS music " .. said .. " loaded and started") + else + singeQuit() + end + end + return OVERLAY_UPDATED +end diff --git a/testScripts/tracker.it b/testScripts/tracker.it new file mode 100644 index 000000000..ec718940b --- /dev/null +++ b/testScripts/tracker.it @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:424d76d2ac417a070c7fe81ee92671361c4a8e5ba6917a43010747f0262f15a5 +size 2300 diff --git a/testScripts/tracker.s3m b/testScripts/tracker.s3m new file mode 100644 index 0000000000000000000000000000000000000000..bc4bf86d8a68b8a2cdbd083765679fd4d7cb93e7 GIT binary patch literal 624 zcmeat&o9vTD$Om>cPdIv%vJ~}N=;5pNzF@6WnhpJU|<knU}RunU|>)XW?~3-4)S$i zG}^%M2Pgmo9E_a*@c>2!CT3)5E(U%E5e7*Hc?Jz2;J^bKz<@D0(AY>X$T6HD*w~jr zl|cb&FNk(vU|?JgVjuyyawIVr6X^b6KOjfJfw56|1H*!U49pCT!kb~zWYMVRF#!#x h%shXD`xO3xT-U_JOqRoNXu>p~s>Wm3iX8VKHUMxlWlI16 literal 0 HcmV?d00001 diff --git a/testScripts/tracker.xm b/testScripts/tracker.xm new file mode 100644 index 000000000..c7f4a0e61 --- /dev/null +++ b/testScripts/tracker.xm @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d3e611f314fc1eaff5ff0f1aab9b41fc4064dddcb85ddd460bae0bd51bdde479 +size 1549 diff --git a/testScripts/tutorialDrive.singe b/testScripts/tutorialDrive.singe index e70a97ed4..63937a0c2 100644 --- a/testScripts/tutorialDrive.singe +++ b/testScripts/tutorialDrive.singe @@ -129,10 +129,10 @@ end -- Opens the tutorial's working copy: a fresh New game, or the previous tutorial's shipped -- description, so each starts where the last left off. function tutorialBegin(fromShipped) - local work = TUTORIAL_OUT .. TUTORIAL.name .. ".game" + local work = TUTORIAL_OUT .. TUTORIAL.name .. ".forge" if fromShipped then - if not authorCopy("Forge/tutorials/" .. fromShipped .. ".game", work) then + if not authorCopy("Forge/tutorials/" .. fromShipped .. ".forge", work) then debugPrint(TUTORIAL.tag .. " FAIL could not copy " .. fromShipped) singeQuit() end @@ -152,7 +152,7 @@ end -- makes runs. A shipped description that is not there yet is written, so a new tutorial's scene -- produces the file the book ships, and the next run checks against it. function tutorialEnd() - local shipped = "Forge/tutorials/" .. TUTORIAL.name .. ".game" + local shipped = "Forge/tutorials/" .. TUTORIAL.name .. ".forge" local built forgeSave() @@ -160,8 +160,8 @@ function tutorialEnd() -- copy has not; the comparison is of the game, not of where it came from. FORGE.game.source = nil if lfs.attributes(shipped, "mode") ~= "file" then - authorCopy(FORGE.path, TUTORIAL_OUT .. TUTORIAL.name .. ".shipped.game") - debugPrint(TUTORIAL.tag .. " WROTE " .. TUTORIAL_OUT .. TUTORIAL.name .. ".shipped.game -- copy it to " .. shipped) + authorCopy(FORGE.path, TUTORIAL_OUT .. TUTORIAL.name .. ".shipped.forge") + debugPrint(TUTORIAL.tag .. " WROTE " .. TUTORIAL_OUT .. TUTORIAL.name .. ".shipped.forge -- copy it to " .. shipped) else local mine = authorCompile(FORGE.game) local theirs = authorCompile(authorLoad(shipped)) diff --git a/thirdparty/SDL3_image/src/IMG_libpng.c b/thirdparty/SDL3_image/src/IMG_libpng.c index eb3eb1256..5ef2a551e 100644 --- a/thirdparty/SDL3_image/src/IMG_libpng.c +++ b/thirdparty/SDL3_image/src/IMG_libpng.c @@ -499,11 +499,17 @@ static bool LIBPNG_LoadPNG_IO_Internal(SDL_IOStream *src, struct png_load_vars * lib.png_read_image(vars->png_ptr, vars->row_pointers); #if SDL_BYTEORDER != SDL_BIG_ENDIAN - if (vars->format == SDL_PIXELFORMAT_RGBA64) { + /* libpng hands 16-bit samples over big-endian; SDL wants the machine's order. RGB48 needs + the swap as much as RGBA64 (Singe: it was left out, and a 16-bit PNG without alpha drew + with its low bytes as colour). */ + if (vars->format == SDL_PIXELFORMAT_RGBA64 || vars->format == SDL_PIXELFORMAT_RGB48) { Uint16 *pixels = (Uint16 *)vars->surface->pixels; - int num_pixels = vars->width * vars->height * 4; - for (int i = 0; i < num_pixels; i++) { - pixels[i] = SDL_Swap16(pixels[i]); + int channels = (vars->format == SDL_PIXELFORMAT_RGBA64) ? 4 : 3; + for (png_uint_32 y = 0; y < vars->height; y++) { + pixels = (Uint16 *)((Uint8 *)vars->surface->pixels + y * (size_t)vars->surface->pitch); + for (int i = 0; i < (int)vars->width * channels; i++) { + pixels[i] = SDL_Swap16(pixels[i]); + } } } #endif diff --git a/util/forgePortActionMax.py b/util/forgePortActionMax.py new file mode 100644 index 000000000..d3c8e27cc --- /dev/null +++ b/util/forgePortActionMax.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +# Writes a Forge description for each ActionMax game beside its original script, from the +# original's own numbers: the parameter file (video lengths, the sensor spot, the thresholds) +# and the sprites' sizes, laid out as Emulator.singe lays them out. Text the emulator renders +# to sprites is measured with its font here, which can sit a pixel from what SDL_ttf measures. +# +# python3 util/forgePortActionMax.py ~/claude/singetest/ActionMax +# +# The descriptions are data: types, rooms (one per state of the emulator), and rules. Run one +# with the port scene in testScripts/ports/, or open it in Forge. + +import os +import re +import sys + +from PIL import Image, ImageFont + +WIDTH = 360 # The overlay the emulator lays itself out on: the engine's default over its +HEIGHT = 240 # video, which every number in it -- the sensor spot included -- assumes. +GAMES = ["38AmbushAlley", "BlueThunder", "Hydrosub2021", "PopsGhostly", "SonicFury"] +TITLES = { + "38AmbushAlley": ".38 Ambush Alley", + "BlueThunder": "Blue Thunder", + "Hydrosub2021": "Hydrosub: 2021", + "PopsGhostly": "Rescue of Pops Ghostly, The", + "SonicFury": "Sonic Fury", +} +AMMO = 5 # Misses allowed in limited ammo mode. +SCORE_SHOWN = 4 # Seconds the floating score stays, as the emulator's heartbeat counts. +LIGHT_SHOWN = 2 # Seconds the light stays lit after a good hit. +SCORE_HEIGHT = 13 # The emulator's line height for the last game's figures. + + +def parameters(path): + values = {} + for line in open(path): + m = re.match(r"^\s*(\w+)\s*=\s*(\"?)([^\"\s]+)\2\s*$", line) + if m: + key, value = m.group(1), m.group(3) + values[key] = value if m.group(2) else int(value) + return values + + +def size(folder, name): + with Image.open(os.path.join(folder, name)) as im: + return im.size + + +def textSize(folder, font, points, text): + face = ImageFont.truetype(os.path.join(folder, font), points) + left, top, right, bottom = face.getbbox(text) + ascent, descent = face.getmetrics() + return right - left, ascent + descent + + +def lua(value): + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return repr(value) if isinstance(value, float) else str(value) + return '"' + value.replace('"', '\\"') + '"' + + +def describe(folder, game): + p = parameters(os.path.join(folder, game + ".singe")) + half = WIDTH / 2 + quarter = half / 2 + intro = p["lengthIntro"] + length = p["lengthGame"] + menu = p["lengthMenu"] + logoW, logoH = size(folder, "sprite_ActionMax.png") + boxW, boxH = size(folder, "sprite_" + game + ".png") + lightW, lightH = size(folder, "sprite_LightOff.png") + bulletW, bulletH = size(folder, "sprite_Bullet.png") + crossW, crossH = size(folder, "sprite_Crosshair.png") + pullW, pullH = textSize(folder, "font_BlueStone.ttf", 20, "Pull Trigger to Start!") + lastW, lastH = textSize(folder, "font_chemrea.ttf", 16, "LAST GAME SCORE") + # Emulator.singe's setup state, number for number. + logoTop = 5 + logoHeight = logoTop + logoH + boxTop = (HEIGHT - (logoHeight + pullH)) / 2 - boxH / 2 + logoHeight + lastGameLeft = quarter - lastW / 2 + lastGameTop = logoHeight + logoTop + scoreTop = lastGameTop + SCORE_HEIGHT + 10 + menuVideo = intro + length + 2 + menuEnd = intro + length + menu + + def entity(kind, ident, x, y): + return "\t\t\t{ type = %s, id = %s, x = %s, y = %s }" % (lua(kind), lua(ident), lua(x), lua(y)) + + def text(name, words, r, g, b, font=None, points=None, anchor=None): + look = "look = { kind = \"text\", text = %s, r = %d, g = %d, b = %d" % (lua(words), r, g, b) + if font: + look += ", font = %s, size = %d" % (lua(font), points) + if anchor: + look += ", anchor = %s" % lua(anchor) + return "\t\t%s = { %s } }," % (name, look) + + types = [ + "\t\tlogo = { look = { kind = \"sprite\", file = \"sprite_ActionMax.png\" } },", + "\t\tboxArt = { look = { kind = \"sprite\", file = %s } }," % lua("sprite_" + game + ".png"), + "\t\tlightOff = { look = { kind = \"sprite\", file = \"sprite_LightOff.png\" } },", + "\t\tlightOn = { look = { kind = \"sprite\", file = \"sprite_LightOn.png\" } },", + "\t\tbullets = { look = { kind = \"sprite\", file = \"sprite_Bullet.png\", step = %d }, vars = { copies = 0 } }," % (-bulletW), + "\t\tcross = { look = { kind = \"sprite\", file = \"sprite_Crosshair.png\" }, behaviours = { { kind = \"pointer\", player = 1 } } },", + "\t\tsensor = { look = { kind = \"none\" },", + "\t\t behaviours = { { kind = \"lightSensor\", x = %d, y = %d, left = %d, top = %d, high = %d, low = %d, trigger = \"SWITCH_BUTTON3\", player = 1 }," + % (p["sensorX"], p["sensorY"], p["sensorLeft"], p["sensorTop"], p["highThreshold"], p["lowThreshold"]), + "\t\t { kind = \"sound\", fire = \"sound_Gunshot.wav\", hit = \"sound_GoodHit.wav\", miss = \"sound_BadHit.wav\" } } },", + "\t\tjingle = { look = { kind = \"none\" }, behaviours = { { kind = \"sound\", spawn = \"sound_ActionMax.wav\" } } },", + text("pull", "Pull Trigger to Start!", 255, 255, 0, "font_BlueStone.ttf", 20, "feet"), + text("ready", "Get Ready!", 255, 255, 0, "font_BlueStone.ttf", 20, "feet"), + text("lastGame", "LAST GAME SCORE", 255, 255, 255, "font_chemrea.ttf", 16, "top"), + text("figure", "", 200, 200, 200, "font_chemrea.ttf", 16), + text("choose", "Select Game Type", 255, 255, 0, "font_chemrea.ttf", 32, "top"), + text("standard1", "Standard", 255, 255, 0, "font_chemrea.ttf", 32, "feet"), + text("standard2", "Game", 255, 255, 0, "font_chemrea.ttf", 32, "top"), + text("limited1", "Limited", 255, 255, 0, "font_chemrea.ttf", 32, "feet"), + text("limited2", "Ammo", 255, 255, 0, "font_chemrea.ttf", 32, "top"), + text("over", "GAME OVER", 255, 0, 0, "font_chemrea.ttf", 48, "feet"), + text("floating", "", 255, 0, 0, "font_LED_Real.ttf", 32), + ] + # The last game's figures, five lines from the emulator's title state. + figures = [ + ("fired", " Shots Fired: ", "shots", 0), + ("good", " Good Hits: ", "good", 1), + ("bad", " Bad Hits: ", "bad", 2), + ("shot", " Shot Score: ", "good - bad", 3), + ("percent", " Game Score: ", "percent", 5), + ] + titleEntities = [ + entity("logo", "logo", half, logoTop + logoH / 2), + entity("boxArt", "boxArt", quarter + half, boxTop + boxH / 2), + entity("lastGame", "lastGame", quarter, lastGameTop), + entity("pull", "pull", half, HEIGHT), + entity("cross", "cross", 0, 0), + entity("sensor", "sensor", 0, 0), + ] + for ident, _, _, line in figures: + titleEntities.append(entity("figure", ident, lastGameLeft, scoreTop + SCORE_HEIGHT * line)) + figureRules = [] + for ident, label, expr, _ in figures: + if expr == "percent": + expr = "shots > 0 and floor((good - bad) / shots * 100) or 0" + words = "%s .. (%s) .. \"%%\"" % (lua(label), expr) + else: + words = "%s .. (%s)" % (lua(label), expr.replace("fired", "shots")) + figureRules.append("\t\t{ note = %s, on = \"frame\", room = \"title\", act = { { \"setText\", entity = %s, text = %s } } }," + % (lua("The last game's " + ident), lua(ident), lua(words))) + out = [] + out.append("-- %s, ported to Forge from ActionMax/Emulator.singe by util/forgePortActionMax.py: the" % TITLES[game]) + out.append("-- emulator's states are rooms, its numbers are these, and a shot is judged by the light sensor.") + out.append("return {") + out.append("\ttitle = %s," % lua(TITLES[game])) + out.append("\tplayers = 1,") + out.append("\tsize = { w = %d, h = %d }," % (WIDTH, HEIGHT)) + out.append("\tlayers = { { kind = \"disc\", file = %s }, { kind = \"overlay\" } }," % lua("frame_" + game + ".txt")) + out.append("\tvocabulary = \"Vocabulary.singe\", -- The light sensor, these games' own (beside them).") + out.append("\tvars = { shots = 0, good = 0, bad = 0, ammo = 0, limited = false, beat = 0, scoreShown = 0, lightShown = 0 },") + out.append("") + out.append("\ttypes = {") + out.extend(types) + out.append("\t},") + out.append("") + out.append("\trooms = {") + out.append("\t\t{ name = \"startup\", reset = true,") + out.append("\t\t entities = {") + out.append(entity("jingle", "jingle", 0, 0)) + out.append("\t\t } },") + out.append("\t\t{ name = \"title\", reset = true,") + out.append("\t\t entities = {") + out.append(",\n".join(titleEntities)) + out.append("\t\t } },") + out.append("\t\t{ name = \"menu\", reset = true,") + out.append("\t\t entities = {") + out.append(",\n".join([ + entity("choose", "choose", half, 25), + entity("standard1", "standard1", quarter, HEIGHT / 2 + 2), + entity("standard2", "standard2", quarter, HEIGHT / 2 + 2), + entity("limited1", "limited1", half + quarter, HEIGHT / 2 + 2), + entity("limited2", "limited2", half + quarter, HEIGHT / 2 + 2), + entity("cross", "cross", 0, 0), + entity("sensor", "sensor", 0, 0), + ])) + out.append("\t\t } },") + out.append("\t\t{ name = \"intro\", reset = true,") + out.append("\t\t entities = {") + out.append(",\n".join([ + entity("ready", "ready", half, HEIGHT), + entity("cross", "cross", 0, 0), + entity("sensor", "sensor", 0, 0), + ])) + out.append("\t\t } },") + out.append("\t\t{ name = \"playing\", reset = true,") + out.append("\t\t entities = {") + out.append(",\n".join([ + entity("floating", "floating", 5, 5), + entity("lightOff", "lightOff", WIDTH - lightW / 2, HEIGHT - lightH / 2), + entity("lightOn", "lightOn", WIDTH - lightW / 2, HEIGHT - lightH / 2), + entity("bullets", "bullets", WIDTH - bulletW - 5 + bulletW / 2, bulletH / 2), + entity("cross", "cross", 0, 0), + entity("sensor", "sensor", 0, 0), + ])) + out.append("\t\t } },") + out.append("\t\t{ name = \"gameOver\", reset = true,") + out.append("\t\t entities = {") + out.append(",\n".join([ + entity("over", "over", half, HEIGHT / 2), + entity("cross", "cross", 0, 0), + entity("sensor", "sensor", 0, 0), + ])) + out.append("\t\t } }") + out.append("\t},") + out.append("") + rules = [ + # Startup: the jingle, then "A steady aim is critical", and the title behind a still. + "\t\t{ note = \"The jingle plays over the title's still\", on = \"roomStart\", room = \"startup\",", + "\t\t act = { { \"discPause\" }, { \"discTo\", frame = %d }, { \"goTo\", room = \"title\" } } }," % p["backgroundFrame"], + "\t\t{ note = \"A steady aim is critical, once the jingle is done\", on = \"soundDone\", file = \"sound_ActionMax.wav\",", + "\t\t act = { { \"playSound\", file = \"sound_ASteadyAimIsCritical.wav\" } } },", + # The heartbeat: a second's tick, as the emulator counts its displays. + "\t\t{ note = \"The heartbeat, once a second\", on = \"frame\", when = { { \"every\", seconds = 1, tag = \"beat\" } },", + "\t\t act = { { \"addVar\", name = \"beat\", amount = 1 }, { \"addVar\", name = \"scoreShown\", amount = -1 }, { \"addVar\", name = \"lightShown\", amount = -1 } } },", + "\t\t{ note = \"Pull to start blinks with the heartbeat\", on = \"frame\", room = \"title\", when = { { \"test\", expr = \"beat % 2 == 1\" } },", + "\t\t act = { { \"show\", entity = \"pull\", visible = true } } },", + "\t\t{ note = \"Pull to start blinks off\", on = \"frame\", room = \"title\", when = { { \"test\", expr = \"beat % 2 == 0\" } },", + "\t\t act = { { \"show\", entity = \"pull\", visible = false } } },", + ] + rules.extend(figureRules) + rules.extend([ + # Title -> menu. + "\t\t{ note = \"The trigger on the title starts the menu video\", on = \"pressed\", switch = \"SWITCH_BUTTON3\", room = \"title\",", + "\t\t act = { { \"discTo\", frame = %d }, { \"discPlay\" }, { \"goTo\", room = \"menu\" } } }," % menuVideo, + "\t\t{ note = \"The menu video loops\", on = \"frame\", room = \"menu\", when = { { \"test\", expr = \"frame >= %d\" } }," % menuEnd, + "\t\t act = { { \"discTo\", frame = %d }, { \"discPlay\" } } }," % menuVideo, + # Menu -> intro: the half of the picture the gun points at chooses the mode. + "\t\t{ note = \"A standard game, from the left half\", on = \"pressed\", switch = \"SWITCH_BUTTON3\", room = \"menu\", when = { { \"test\", expr = \"pointerX(1) < %d\" } }," % int(half), + "\t\t act = { { \"setVar\", name = \"limited\", value = \"false\" }, { \"setVar\", name = \"shots\", value = \"0\" }, { \"setVar\", name = \"good\", value = \"0\" }, { \"setVar\", name = \"bad\", value = \"0\" },", + "\t\t { \"discTo\", frame = 1 }, { \"discPlay\" }, { \"playSound\", file = \"sound_GetReadyForAction.wav\" }, { \"goTo\", room = \"intro\" } } },", + "\t\t{ note = \"Limited ammo, from the right half\", on = \"pressed\", switch = \"SWITCH_BUTTON3\", room = \"menu\", when = { { \"test\", expr = \"pointerX(1) >= %d\" } }," % int(half), + "\t\t act = { { \"setVar\", name = \"limited\", value = \"true\" }, { \"setVar\", name = \"ammo\", value = \"%d\" }, { \"setVar\", name = \"shots\", value = \"0\" }, { \"setVar\", name = \"good\", value = \"0\" }, { \"setVar\", name = \"bad\", value = \"0\" }," % AMMO, + "\t\t { \"discTo\", frame = 1 }, { \"discPlay\" }, { \"playSound\", file = \"sound_GetReadyForAction.wav\" }, { \"goTo\", room = \"intro\" } } },", + # Intro: get ready blinks; the game begins when the intro's frames are done. + "\t\t{ note = \"Get ready blinks with the heartbeat\", on = \"frame\", room = \"intro\", when = { { \"test\", expr = \"beat % 2 == 1\" } },", + "\t\t act = { { \"show\", entity = \"ready\", visible = true } } },", + "\t\t{ note = \"Get ready blinks off\", on = \"frame\", room = \"intro\", when = { { \"test\", expr = \"beat % 2 == 0\" } },", + "\t\t act = { { \"show\", entity = \"ready\", visible = false } } },", + "\t\t{ note = \"The intro is over: the game\", on = \"frame\", room = \"intro\", when = { { \"test\", expr = \"frame >= %d\" } }," % intro, + "\t\t act = { { \"discTo\", frame = %d }, { \"discPlay\" }, { \"goTo\", room = \"playing\" } } }," % (intro + 1), + # Playing: shots, hits, the light, the score, the ammo. + "\t\t{ note = \"A shot is fired\", on = \"pressed\", switch = \"SWITCH_BUTTON3\", room = \"playing\",", + "\t\t act = { { \"addVar\", name = \"shots\", amount = 1 } } },", + "\t\t{ note = \"A shot is fired with limited ammo: one fewer, given back on a hit\", on = \"pressed\", switch = \"SWITCH_BUTTON3\", room = \"playing\", when = { { \"test\", expr = \"limited\" } },", + "\t\t act = { { \"addVar\", name = \"ammo\", amount = -1 } } },", + "\t\t{ note = \"A good hit\", on = \"hit\", room = \"playing\",", + "\t\t act = { { \"addVar\", name = \"good\", amount = 1 }, { \"setVar\", name = \"lightShown\", value = \"%d\" }, { \"setVar\", name = \"scoreShown\", value = \"%d\" } } }," % (LIGHT_SHOWN, SCORE_SHOWN), + "\t\t{ note = \"A good hit with limited ammo gives the round back\", on = \"hit\", room = \"playing\", when = { { \"test\", expr = \"limited\" } },", + "\t\t act = { { \"addVar\", name = \"ammo\", amount = 1 } } },", + "\t\t{ note = \"A bad hit: a good guy\", on = \"miss\", room = \"playing\",", + "\t\t act = { { \"addVar\", name = \"bad\", amount = 1 }, { \"setVar\", name = \"scoreShown\", value = \"%d\" } } }," % SCORE_SHOWN, + "\t\t{ note = \"The score floats while it is fresh\", on = \"frame\", room = \"playing\",", + "\t\t act = { { \"setText\", entity = \"floating\", text = \"scoreShown > 0 and ('SCORE: ' .. (good - bad)) or ''\" } } },", + "\t\t{ note = \"The light is lit after a good hit\", on = \"frame\", room = \"playing\", when = { { \"test\", expr = \"lightShown > 0\" } },", + "\t\t act = { { \"show\", entity = \"lightOn\", visible = true }, { \"show\", entity = \"lightOff\", visible = false } } },", + "\t\t{ note = \"The light is off\", on = \"frame\", room = \"playing\", when = { { \"test\", expr = \"lightShown <= 0\" } },", + "\t\t act = { { \"show\", entity = \"lightOn\", visible = false }, { \"show\", entity = \"lightOff\", visible = true } } },", + "\t\t{ note = \"The rounds left, with limited ammo\", on = \"frame\", room = \"playing\",", + "\t\t act = { { \"setVar\", entity = \"bullets\", name = \"copies\", value = \"limited and max(ammo, 0) or 0\" } } },", + "\t\t{ note = \"Out of ammo: game over\", on = \"frame\", room = \"playing\", when = { { \"test\", expr = \"limited and ammo < 0\" } },", + "\t\t act = { { \"discTo\", frame = %d }, { \"discPlay\" }, { \"playSound\", file = \"sound_GameOver.wav\" }, { \"goTo\", room = \"gameOver\" } } }," % menuVideo, + "\t\t{ note = \"The game video is over: the title\", on = \"frame\", room = \"playing\", when = { { \"test\", expr = \"frame >= %d\" } }," % (intro + length), + "\t\t act = { { \"discPause\" }, { \"discTo\", frame = %d }, { \"goTo\", room = \"title\" } } }," % p["backgroundFrame"], + # Game over -> title. + "\t\t{ note = \"Game over is shown until the menu video ends\", on = \"frame\", room = \"gameOver\", when = { { \"test\", expr = \"frame >= %d\" } }," % menuEnd, + "\t\t act = { { \"discPause\" }, { \"discTo\", frame = %d }, { \"goTo\", room = \"title\" } } }," % p["backgroundFrame"], + ]) + out.append("\trules = {") + out.append("\n".join(rules).rstrip(",")) + out.append("\t}") + out.append("}") + return "\n".join(out) + "\n" + + +def main(): + folder = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/claude/singetest/ported/ActionMax") + for game in GAMES: + path = os.path.join(folder, game + ".forge") + with open(path, "w") as out: + out.write(describe(folder, game)) + print("wrote " + path) + + +if __name__ == "__main__": + main() diff --git a/util/forgePortKaris.lua b/util/forgePortKaris.lua new file mode 100644 index 000000000..c0c4d2d19 --- /dev/null +++ b/util/forgePortKaris.lua @@ -0,0 +1,699 @@ +-- Writes a Forge description for a KarisFramework game from the game's own script: the script is +-- Lua, so it is loaded with the engine stubbed out, its settings and tables read, and setupMoves +-- called for every level and scene. Nothing is transcribed by hand. +-- +-- lua5.4 util/forgePortKaris.lua <game directory> <main script> [<name>] +-- lua5.4 util/forgePortKaris.lua ~/claude/singetest/ported/TimeGal Timegal.singe +-- lua5.4 util/forgePortKaris.lua ~/claude/singetest/ported/hypseus/Astroboy/singe/Astroboy Astroboy.singe +-- lua5.4 util/forgePortKaris.lua ~/claude/singetest/hypseus/starblazers/singe/starblazers starblazers.singe +-- +-- The description goes beside the script as <name>.forge (the script's stem unless given). It is +-- data for the qte behaviour in Author.singe, which plays the framework's game loop. +-- +-- The framework is found where the game keeps it: KarisFramework/Script beside the script (the +-- library's games), Structure or Script beside it, or Framework or FrameworkKimmy beside the +-- game's folder (the Hypseus zip-ROM layout, singe/<Title>/ and singe/Framework/). Its +-- version is written into the description, since 3.32b differs from 3.31c in a few things a +-- player can see (and MazescaterFramework beside the game, LINEA's Karis 3.31c with compound +-- moves, is marked as such there); a Kimmy Script Engine game gets the kimmy behaviour, and its saves and its +-- die-and-retry records are snapshotted too, since its new-game menu reads them. A game of +-- RDG's map-mode lineage (no framework folder: its globals.singe is beside its script and its +-- levels are functions of its main.singe) gets the rdg behaviour, which runs those functions +-- itself; its moves are not snapshotted, since the game draws some of them at random at play. + +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$", "") + +-- ===== Loading the game's script ============================================================== + +local env = setmetatable({}, { __index = _G }) + +local function nothing() return 0 end + +for _, key in ipairs({ "soundLoad", "spriteLoad", "fontLoad", "fontSelect", "fontQuality", "colorForeground", "colorBackground", "discGetWidth", "discGetHeight", "overlaySetResolution", "discSetFPS", "debugPrint", "discSkipToFrame", "discPause", "discPlay", "singeSetGameName", "keyboardSetMode", "overlayClear", "singeGetDataPath", "spriteGetWidth", "spriteGetHeight", "setOverlaySize", "setOverlayResolution", "scoreBezelEnable", "scoreBezelGetState", "discAudioSuffix", "singeSetQuitKeyEnabled", "keyboardCatchQuit", "ratioGetX", "ratioGetY", "vldpGetScale", "overlayGetWidth", "overlayGetHeight", "fontGetHeight", "fontGetWidth", "spriteLoadFrames", "mouseGetPosition", "keyboardCatchQuit", "singeSetQuitKeyEnabled" }) do + env[key] = nothing +end +-- Where the framework's globals are, and its version from their header. +local frameworkFile +local frameworkVersion +local kimmy = false +local rdg = false +local timegal = false +local sdq = false +local mazescater = false +-- An American Laser Games title (Mad Dog McCree and its kin): no framework at all, its +-- hitbox files beside the script; the alg behaviour plays it over the level flows written +-- by hand in <stem>.levels.lua beside the script. +local alg = (function() + local handle = io.open(dir .. "/" .. main, "rb") + local text = handle and handle:read("a") or "" + + if handle then + handle:close() + end + -- The family's mark: a shot judged against a hitmap of the frame (shooterHit), its + -- hitbox files loaded by name, or the typing edition's own globals. + return (text:find("function shooterHit", 1, true) ~= nil) or (text:find('dofile(MYDIR .. "hitbox-', 1, true) ~= nil) or (text:find("te-globals.singe", 1, true) ~= nil) +end)() +-- Adventures in Videoland (Rollercoaster): a text adventure over the disc, its rooms, objects, +-- and furniture tables in the script; the videoland behaviour plays its loop over them. +local videoland = (function() + local handle = io.open(dir .. "/" .. main, "rb") + local text = handle and handle:read("a") or "" + + if handle then + handle:close() + end + return (text:find("STATE_GET_NAME", 1, true) ~= nil) and (text:find("ROOMS", 1, true) ~= nil) +end)() +-- Hologram Time Traveler (RDG2010's Singe edition): its own loop over segment tables shuffled +-- at start, read like the American Laser Games titles (the script is its data) and played by +-- the timetraveler behaviour in the vocabulary beside it. +local timetraveler = (function() + local handle = io.open(dir .. "/" .. main, "rb") + local text = handle and handle:read("a") or "" + + if handle then + handle:close() + end + return text:find("TIME TRAVELER (SINGE EDITION)", 1, true) ~= nil +end)() +if videoland or timetraveler then + alg = true +end + +-- (hq-globals.singe, Time Gal HD's, comes before a plain globals.singe beside it: that title +-- keeps its Singe 1 ancestor's files too.) +for _, candidate in ipairs({ dir .. "/MazescaterFramework/Script/globals.singe", dir .. "/KarisFramework/Script/globals.singe", dir .. "/Structure/globals.singe", dir .. "/Script/globals.singe", dir .. "/../Framework/globals.singe", dir .. "/../FrameworkKimmy/globals.singe", dir .. "/hq-globals.singe", dir .. "/globals.singe" }) do + local handle = io.open(candidate, "r") + + if handle then + local text = handle:read("a") or "" + + -- The MazescaterFramework (LINEA): Karis 3.31c with compound moves, its version + -- marked as the framework's own so the loop tells the two apart. + mazescater = (candidate == dir .. "/MazescaterFramework/Script/globals.singe") + frameworkFile = candidate + frameworkVersion = (mazescater and "Mazescater " or "") .. (text:match("VERSION:%s*([%w%.]+)") or "") + kimmy = text:find("KIMMY SCRIPT ENGINE", 1, true) ~= nil + timegal = (candidate == dir .. "/hq-globals.singe") + rdg = (candidate == dir .. "/globals.singe") or timegal + -- Super Don Quixote: map-mode data under a Karis 3.31c judging loop, whose globals + -- name the Karis kinds (HOLDUP and the rest). + sdq = rdg and (not timegal) and (text:find("HOLDUP", 1, true) ~= nil) + handle:close() + break + end +end +if alg then + -- The game's own script is its globals: everything it declares at load is its data. + frameworkFile = dir .. "/" .. main + frameworkVersion = nil + rdg = true +end +assert(frameworkFile, "no framework globals beside " .. dir) +env.singeGetScriptPath = function() return dir .. "/" .. main end +env.random = { new = function() return 0 end } +-- The map-mode scripts seed from os.clock at load; plain Lua wants a whole number for that. +env.os = setmetatable({ clock = function() return 1 end }, { __index = os }) +-- A game that asks the filesystem whether its saved config exists (cfgReadPath, through lfs) +-- hears that it does not, and reads the shipped copy. +env.require = function() return { attributes = function() return nil end } end +-- A game that reads its config at load (Mad Dog McCree's readConfig runs as the script does) +-- reads the real file when the path names one, and nothing otherwise. +local ioCurrent = nil +env.io = { open = function() return nil end, + input = function(path) + -- The path as given, else its name under the game's Cfg/ folder (the script's + -- MYDIR is a stub here). + local base = (type(path) == "string") and path:match("([^/\\]+)$") or nil + + ioCurrent = nil + for _, candidate in ipairs({ path, base and (dir .. "/../Cfg/" .. base), base and (dir .. "/Cfg/" .. base), base and (dir .. "/" .. base) }) do + if (type(candidate) == "string") and (ioCurrent == nil) then + ioCurrent = io.open(candidate, "r") + end + end + return ioCurrent + end, + output = function() return nil end, + read = function(what) + if ioCurrent then + return ioCurrent:read(what or "l") + end + return nil + end, + write = function() end, + close = function(handle) + if handle and (handle == ioCurrent) then + ioCurrent:close() + ioCurrent = nil + end + end, + lines = function() return function() return nil end end } +env.mouseHowMany = function() return 0 end +env.discSearch = function() end +env.discSetFPS = function() end +env.singeWantsCrosshairs = function() return false end +-- A setupMoves that reads the game's state (Space Ace picks a scene by which levels are +-- beaten) sees nothing beaten here; the qte behaviour runs the script itself as it plays. +env.stage = setmetatable({}, { __index = function() return { false, false, 0 } end }) +env.scene = setmetatable({}, { __index = function() return setmetatable({}, { __index = function() return { 0, false } end }) end }) +env.Tiers = {} +env.Level = {} +env.Death = {} +env.move = {} +env.choice = {} +env.path = {} +env.timed = {} +env.dofile = function(path) + if alg and (path ~= frameworkFile) then + -- The title's own files (its hitbox tables, its board, its service) run here too, + -- under the stubs: the script calls what they define at load (readConfig). + -- (Beside the script, or in a Script/ folder beside it, as the HD copies keep theirs.) + local base = path:match("([^/\\]+)$") + local own = loadfile(dir .. "/" .. base, "t", env) or loadfile(dir .. "/Script/" .. base, "t", env) + + if own then + own() + end + return + end + if path:match("globals%.singe$") then + -- The framework's globals name the moves; what it loads beyond that is stubbed. + local chunk = assert(loadfile(frameworkFile, "t", env)) + local inner = env.dofile + + env.dofile = function() end + chunk() + env.dofile = inner + end +end + +if alg then + -- A hand-written game touches whatever engine calls it likes at load (videoGetVolume, ...): + -- any name the stubs do not know answers as one that does nothing. + for _, name in ipairs({ "pairs", "ipairs", "next", "type", "tonumber", "tostring", "select", "pcall", "error", "assert", "setmetatable", "getmetatable", "rawget", "rawset", "rawequal", "print", "unpack" }) do + if rawget(env, name) == nil then + env[name] = _G[name] or (name == "unpack" and table.unpack) or nil + end + end + setmetatable(env, { __index = function(_, key) + local real = _G[key] + + if real ~= nil then + return real + end + return nothing + end }) + -- Its framework.singe defines the switch names and the defaults the script reads. + local frame = loadfile(dir .. "/framework.singe", "t", env) + + if frame then + frame() + end +end +-- The game's own directory (the Script/ folder's parent when it has one), for the files the +-- script joins onto MYDIR at load. +env.MYDIR = (io.open(dir .. "/../Cfg/", "r") or io.open(dir .. "/../Script/", "r")) and (dir .. "/..") or dir +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() + local found = {} + + for _, file in ipairs({ dir .. "/Cfg/game.cfg", dir .. "/Cfg/default.cfg" }) do + local handle = io.open(file, "r") + + if handle then + for line in handle:lines() do + local key, value = line:match("^%s*([%w_]+)%s*=%s*(-?%d+)") + + if key and (found[key] == nil) then + found[key] = tonumber(value) + end + end + handle:close() + end + end + + return found +end + +-- ===== The moves ============================================================================== + +local function copy(t) + local out = {} + + for key, value in pairs(t or {}) do + -- A function is not data (a stub of the converter's, a game's own helper). + if type(value) == "table" then + out[key] = copy(value) + elseif type(value) ~= "function" then + out[key] = value + end + end + + return out +end + +-- A game's moves may differ by difficulty (Space Ace's do: setupMoves reads dip_Difficulty): +-- each scene is read at every difficulty, and kept once when they agree. +local function scenesAt(level, difficulty) + local scenes = {} + + env.dip_Difficulty = difficulty + for scene = 1, env.Level[level][4] do + env.move = {} + env.choice = {} + env.path = {} + env.timed = {} + env.sceneStart = nil + env.sceneEnd = nil + env.totalMoves = 0 + env.setupMoves(level, scene) + local moves = {} + + for index = 1, env.totalMoves or 0 do + moves[index] = copy(env.move[index]) + end + scenes[scene] = { start = env.sceneStart, finish = env.sceneEnd, moves = moves, choices = copy(env.choice), paths = copy(env.path), timed = copy(env.timed) } + end + + return scenes +end + + +local function same(a, b) + if type(a) ~= type(b) then + return false + end + if type(a) ~= "table" then + return a == b + end + for key, value in pairs(a) do + if not same(value, b[key]) then + return false + end + end + for key in pairs(b) do + if a[key] == nil then + return false + end + end + + return true +end + + +-- The scenes of a level: play, or playBy difficulty when the difficulties differ. +local function scenesOf(level) + local by = {} + local differ = false + + for difficulty = 0, 3 do + by[difficulty] = scenesAt(level, difficulty) + if not same(by[difficulty], by[0]) then + differ = true + end + end + env.dip_Difficulty = nil + if differ then + return nil, by + end + + return by[0], nil +end + +-- ===== Writing ================================================================================ + +local out = {} + +local function put(line) + out[#out + 1] = line +end + +local function lua(value, indent) + indent = indent or "" + if type(value) == "table" then + local keys = {} + local isList = (#value > 0) + local parts = {} + + for key in pairs(value) do + keys[#keys + 1] = key + end + table.sort(keys, function(a, b) + if type(a) == type(b) then + return a < b + end + return type(a) == "number" + end) + for _, key in ipairs(keys) do + local item = lua(value[key], indent .. "\t") + + if isList and (type(key) == "number") and (key >= 1) and (key <= #value) and (math.floor(key) == key) then + parts[#parts + 1] = item + elseif type(key) == "string" and key:match("^[%a_][%w_]*$") then + parts[#parts + 1] = key .. " = " .. item + else + parts[#parts + 1] = "[" .. lua(key) .. "] = " .. item + end + end + local text = "{ " .. table.concat(parts, ", ") .. " }" + + if #text > 110 then + text = "{\n" .. indent .. "\t" .. table.concat(parts, ",\n" .. indent .. "\t") .. "\n" .. indent .. "}" + end + return text + elseif type(value) == "string" then + return string.format("%q", value) + end + + return tostring(value) +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 } +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 } +end + +local tiers = {} + +for index = 0, (env.Tiers and env.Tiers[0] and env.Tiers[0][1]) or 0 do + tiers[index + 1] = copy(env.Tiers[index]) +end + +local dips = settings() + +-- A map-mode game keeps its dips and its board in one file, the one its service.singe reads +-- (game.cfg or the game's own name beside the script; the later copies read theirs from Cfg/ +-- beside the Script/ folder, through cfgReadPath): the dips as name = value lines (the names +-- are the game's own), a blank, then ten lines of NAME,score. +local function mapModeConfigFile() + local handle = (timegal and io.open(dir .. "/hq-service.singe", "rb")) or io.open(dir .. "/service.singe", "rb") or io.open(dir .. "/Script/service.singe", "rb") or io.open(dir .. "/te-service.singe", "rb") + local name = "game.cfg" + + if handle then + local text = handle:read("a") + + handle:close() + -- The config the game reads first: through cfgReadPath (the later copies, whose + -- MYDIR reads name a default.cfg fallback too), else joined onto MYDIR. + name = text:match('io%.input%(cfgReadPath%("([^"]+)"%)') or text:match('io%.input%(MYDIR%s*%.%.%s*"([^"]+)"') or text:match('io%.input,%s*MYDIR%s*%.%.%s*"([^"]+)"') or text:match('io%.input%("[^"]-([^"/]+%.cfg)"%)') or name + name = name:gsub("^/", "") + end + for _, candidate in ipairs({ dir .. "/" .. name, dir .. "/../Cfg/" .. name, dir .. "/../" .. name }) do + local found = io.open(candidate, "r") + + if found then + found:close() + return candidate + end + end + + return dir .. "/" .. name +end + + +local function mapModeConfig() + local found = {} + local board = {} + local extra = {} -- Super Don Quixote's best percent per level, after its board. + local handle = io.open(mapModeConfigFile(), "r") + + if handle then + for line in handle:lines() do + local key, value = line:match("^%s*([%w_]+)%s*=%s*(-?%d+)") + local who, score = line:match("^%s*([^,=]+),(%d+)%s*$") + + if key then + found[key] = tonumber(value) + elseif who and (#board < 10) then + board[#board + 1] = { who, tonumber(score) } + elseif who then + extra[#extra + 1] = { who, tonumber(score) } + end + end + handle:close() + end + + return found, board, extra +end + +-- What a map-mode title does differently from the lineage, by the script's stem: the sound of +-- a right move when it is not sndright, and how long the intro's command screen holds. +local RDG_TWEAKS = { + freedomfighter = { rightSound = "sndshot", introHold = 15 } +} + +if rdg then + local rdgDips, rdgBoard, rdgExtra = mapModeConfig() + + dips = rdgDips + RDG_BOARD = rdgBoard + RDG_EXTRA = rdgExtra +end + +-- Super Don Quixote's level order is two tables hardcoded in its doLevel: the level after a +-- beaten one, and the level after a scene that did not beat its level (the game alternates +-- two levels' scenes); read from the script's text as the game's data. +local function sdqOrder() + local handle = io.open(dir .. "/main.singe", "rb") + local beaten = {} + local unfinished = {} + + if handle == nil then + return nil + end + local text = handle:read("a") + + handle:close() + local body = text:match("function doLevel%(%).-\nend") or "" + local split = body:find("if CompleteLevel then", 1, true) or #body + local before, after = body:sub(1, split), body:sub(split) + + for from, to in before:gmatch("if%s*%(thisLevel == level(%d+)%)%s*then%s*iCurPos%s*=%s*level(%d+)") do + beaten[tonumber(from)] = tonumber(to) + end + for from, to in after:gmatch("if%s*%(thisLevel == level(%d+)%)%s*then%s*iCurPos%s*=%s*level(%d+)") do + unfinished[tonumber(from)] = tonumber(to) + end + + return { beaten = beaten, unfinished = unfinished } +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 = {} + local handle = io.open(dir .. "/Cfg/hscore.cfg", "r") + + if handle then + for line in handle:lines() do + local who, score = line:match("^%s*([^,]+),(%d+)") + + if who and (#found < 10) then + found[#found + 1] = { who, tonumber(score) } + end + end + handle:close() + end + + return found +end + +-- Kimmy's "die and retry" records, one per difficulty: the four lines after the three boards +-- and the two trophy blocks of Cfg/hscore.cfg, read as the framework reads them (a line +-- without a comma-and-name counts 100). +local function dieAndRetry() + local records = { 100, 100, 100, 100 } + local handle = io.open(dir .. "/Cfg/hscore.cfg", "r") + + if handle == nil then + return records + end + local lines = {} + + for line in handle:lines() do + lines[#lines + 1] = line + end + handle:close() + -- 10 + blank, 10 + blank, 10 + blank, 4 + blank, 4 + blank: the records begin at line 44. + for k = 1, 4 do + local line = lines[43 + k] + local score = line and (line:find("!", 1, true) == nil) and line:match("^[^,]*,(.*)$") + + records[k] = tonumber(score) or 100 + end + + return records +end + +-- Kimmy's save slots, Cfg/s1.cfg to s6.cfg: a line of eleven fields in the framework's +-- punctuation, then a line per level (its order, started, beaten, deaths). +local function saves() + local slots = {} + + for slot = 1, 6 do + local handle = io.open(dir .. "/Cfg/s" .. slot .. ".cfg", "r") + + if handle then + local first = handle:read("l") or "" + local line = {} + local rest = first + + for _, mark in ipairs({ ",", "!", "?", ";", ":", "A", "B", "C", "D", "E", "F" }) do + local at = rest:find(mark, 1, true) + + if at == nil then + break + end + line[#line + 1] = rest:sub(1, at - 1) + rest = rest:sub(at + 1) + end + local levels = {} + + for entry in handle:lines() do + local order, started, beaten, deaths = entry:match("^(%d+)A(%a+)B(%a+)C(%d+)D") + + if order then + levels[#levels + 1] = { tonumber(order), started == "true", beaten == "true", tonumber(deaths) } + end + end + handle:close() + slots[slot] = { line = line, levels = levels } + end + end + + return slots +end + +-- Everything the game's script declared, under its own names: the scores, the offsets, the +-- flags, the Level and Death tables, the play order and the tiers. The qte behaviour takes +-- these as its state, and runs the script itself when it can, for a setupMoves that reads the +-- state as it plays. +local settings = {} + +for key, value in pairs(env) do + local kind = type(value) + + if (kind == "number") or (kind == "string") or (kind == "boolean") or (kind == "table") then + if not (key == "move" or key == "choice" or key == "path" or key == "timed" or key == "io" or key == "os" or key == "string" or key == "math" or key == "table" or key == "sceneStart" or key == "sceneEnd" or key == "totalMoves" or key == "dip_Difficulty" or key == "MYDIR" or key == "BASEDIR" or key == "SINGE_LEGACY_SPRITE_ARGS" or key == "random" or key == "os") then + settings[key] = (kind == "table") and copy(value) or value + end + end +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 + +-- The level flows of an American Laser Games title, written by hand beside its script. +local function algLevels() + local chunk = loadfile(dir .. "/" .. main:gsub("%.singe$", "") .. ".levels.lua", "t", {}) + + return chunk and chunk() or nil +end + +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, + 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 +} + +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("-- 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) .. ",") + put("\tvocabulary = \"Vocabulary.singe\",") + put("\tplayers = 1,") + put("\tlayers = { { kind = \"disc\" }, { kind = \"overlay\" } },") + put("\tvars = { room = 1, turns = 0 },") + put("") + put("\ttypes = {") + put("\t\tgame = { look = { kind = \"console\" }, behaviours = { { kind = \"videoland\" } } }") + put("\t},") + put("") + put("\trooms = {") + put("\t\t{ name = \"play\", entities = { { type = \"game\", id = \"game\", x = 0, y = 0 } } }") + put("\t},") + put("") + put("\trules = {},") + put("") + put("\tvideoland = " .. lua({ script = main, rooms = env.ROOMS, objects = env.OBJECTS, furniture = env.FURNITURE, directions = env.DIRECTIONS, turns = 150 }, "\t")) + put("}") + local path = dir .. "/" .. name .. ".forge" + local file = assert(io.open(path, "w")) + + file:write(table.concat(out, "\n") .. "\n") + file:close() + print("wrote " .. path .. ": " .. #env.ROOMS .. " rooms, " .. #env.OBJECTS .. " objects") + 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")))) + +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("return {") +put("\ttitle = " .. lua(name) .. ",") +if alg then + -- The title's mechanics and screens are a vocabulary beside its script. + put("\tvocabulary = \"Vocabulary.singe\",") +end +put("\tplayers = 1,") +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 = {") +-- A hand-written title's vocabulary draws its HUD as its own files do (the hud look); a +-- framework's is drawn by the runtime's look for that loop. +put("\t\tgame = { look = { kind = \"" .. (alg and "hud" or (loop .. "Hud")) .. "\" }, behaviours = { { kind = \"" .. loop .. "\" } } }") +put("\t},") +put("") +put("\trooms = {") +put("\t\t{ name = \"play\", entities = { { type = \"game\", id = \"game\", x = 0, y = 0 } } }") +put("\t},") +put("") +put("\trules = {},") +put("") +put("\tqte = " .. lua(qte, "\t")) +put("}") + +local path = dir .. "/" .. name .. ".forge" +local file = assert(io.open(path, "w")) + +file:write(table.concat(out, "\n") .. "\n") +file:close() +print("wrote " .. path .. ": " .. #levels .. " levels, " .. #env.Death .. " deaths") diff --git a/util/forgePorts.py b/util/forgePorts.py new file mode 100644 index 000000000..83a13ec8c --- /dev/null +++ b/util/forgePorts.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +# Assembles the Forge ports of the test library (its ported originals, singetest/ported) into a project folder of their own, one game to a +# folder, each a complete Singe game: the description, the files it plays with, the compiled +# port beside a copy of the runtime, and a games.dat so the menu lists it (FORGE.md section 14.8). +# +# python3 util/forgePorts.py [<library>] [<out>] +# python3 util/forgePorts.py ~/claude/singetest/ported ~/claude/singePorts +# +# SINGE the engine binary that compiles the descriptions (default: the newest build) +# +# A folder is named as the library names it (TimeGal, ChantzesStone with both of its games), and +# the five ActionMax games, which share one folder there, get one each. A Hypseus title +# (hypseus/<Title>/singe/<Title>/) is flattened to <Title>/ with its framefile and Video/ beside +# the scripts, and given the games.dat it never had. Nothing that belongs to the original +# implementation travels: not the KarisFramework copies (nor LINEA's MazescaterFramework), not +# the ActionMax emulator, and not the original games.dat. A game's own script does travel, since the Karis port plays it. +# Video is hard-linked rather than copied (the library's videos are not edited, and copying them +# would be some forty gigabytes); cp --remove-destination materialises one. +# +# The compiled port is <Stem>.port.singe, never the game's own name: a built script that shared +# the script's name would be loading the game inside itself. + +import glob +import os +import re +import shutil +import subprocess +import sys +import tempfile + +LIBRARY = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/claude/singetest/ported") +OUT = os.path.expanduser(sys.argv[2] if len(sys.argv) > 2 else "~/claude/singePorts") +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +LINKED = {".mkv", ".mp4", ".m4v", ".mpg", ".mpeg", ".avi", ".m2v", ".ogg"} # Video: hard-linked, not copied. +HYPSEUS = "hypseus" +ACTIONMAX = "ActionMax" +# What a Karis port never reads: the framework (the qte loop plays its part), the original +# games.dat, and the folders only the framework's own drawing opened -- the overlay pictures +# and fonts (the loop draws its own text), the launcher art in Assets/ (the menu takes its +# pictures from Menu/, which games.dat names), and a Hypseus title's Structure/ (its framework). +NOT_KARIS = {"KarisFramework", "MazescaterFramework", "games.dat", "Assets", "Structure"} + + +def engine(): + given = os.environ.get("SINGE") + if given: + return os.path.abspath(given) + found = sorted(glob.glob(os.path.join(REPO, ".builddir", "linux-gcc", "*", "singe", "Singe-v*")) + glob.glob(os.path.join(REPO, ".builddir", "linux", "*", "singe", "Singe-v*")), key=os.path.getmtime) + if not found: + sys.exit("forgePorts: no engine binary; set SINGE") + return found[-1] + + +def entries(datPath): + # The GAMES entries of a games.dat, each as its text, by the script it names. + text = open(datPath).read() + found = {} + depth = 0 + start = None + for at, char in enumerate(text): + if char == "{": + depth += 1 + if depth == 2: + start = at + elif char == "}": + if depth == 2 and start is not None: + entry = text[start:at + 1] + script = re.search(r'SCRIPT\s*=\s*"([^"]*)"', entry) + if script: + found[os.path.basename(script.group(1))] = entry + depth -= 1 + return found + + +def framefileVideos(path): + # The files a framefile names, relative to its own directory: its first line is the + # directory the videos are in (".", "Video/"), the rest "frame name" lines. A video's + # audio track sits beside it with the same stem and an .ogg or .wav suffix. + with open(path, errors="replace") as handle: + lines = [line.strip() for line in handle] + if not lines: + return [] + base = lines[0].replace("\\", "/").rstrip("/") + videos = [] + for line in lines[1:]: + parts = line.split() + if len(parts) >= 2 and re.match(r"^\d+$", parts[0]): + name = parts[1] if base in ("", ".") else os.path.join(base, parts[1]) + videos.append(name) + stem = os.path.splitext(name)[0] + for suffix in (".ogg", ".wav"): + if os.path.exists(os.path.join(os.path.dirname(path), stem + suffix)): + videos.append(stem + suffix) + return videos + + +def isFramefile(path): + # A framefile opens with the directory its videos are in, then lines of frame and file. + with open(path, errors="replace") as handle: + first = handle.readline().strip() + rest = handle.read() + return (first == "." or first.endswith(("/", "\\"))) and re.search(r"^\d+\s+\S+\.(m2v|mkv|mp4|mpg)", rest, re.M) is not None + + +def luaString(text): + return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def place(source, target): + # A file into the project: video by hard link, the rest by copy. + os.makedirs(os.path.dirname(target), exist_ok=True) + if os.path.exists(target): + os.remove(target) + if os.path.splitext(source)[1].lower() in LINKED: + try: + os.link(source, target) + return + except OSError: + pass + shutil.copy2(source, target) + + +def placeTree(source, target, skip, skipExtensions=()): + # A symbolic link to a directory is not followed: a flat Hypseus title links singe/<Title> + # back to itself. + for name in sorted(os.listdir(source)): + if name in skip or name.endswith(skipExtensions): + continue + path = os.path.join(source, name) + if os.path.isdir(path) and not os.path.islink(path): + placeTree(path, os.path.join(target, name), set()) + elif not os.path.isdir(path): + place(path, os.path.join(target, name)) + + +def portEntry(entry, folder, stem, fromFolder): + # The original's entry, made the port's: the compiled port as its script, paths under the + # new folder, and the engine's own sprite calls rather than the legacy ones. + entry = entry.replace('"%s/' % fromFolder, '"%s/' % folder) + entry = re.sub(r'(SCRIPT\s*=\s*)"[^"]*"', r'\1"%s/%s.port.singe"' % (folder, stem), entry) + entry = re.sub(r'(DATA\s*=\s*)"[^"]*"', r'\1"%s"' % folder, entry) + entry = re.sub(r'(LEGACY_SPRITE_ARGS\s*=\s*)true', r'\1false', entry) + return entry + + +def writeDat(folder, ports): + with open(os.path.join(OUT, folder, "games.dat"), "w") as dat: + dat.write("-- Written by util/forgePorts.py, from the original's games.dat where it had one; the scripts are the Forge ports.\n") + dat.write("GAMES = {\n") + dat.write(",\n".join("\t" + entry.strip() for entry in ports)) + dat.write("\n}\n") + + +def main(): + builds = [] # (description, compiled port) + + # The ActionMax games: one folder each, with what the game and the family share. + source = os.path.join(LIBRARY, ACTIONMAX) + shared = [name for name in os.listdir(source) if re.match(r"^(font|sound|sprite|video|marquee)_", name) or name == "Vocabulary.singe"] + games = sorted(os.path.splitext(os.path.basename(path))[0] for path in glob.glob(os.path.join(source, "*.forge"))) + dat = entries(os.path.join(source, "games.dat")) + for game in games: + folder = os.path.join(OUT, game) + shutil.rmtree(folder, ignore_errors=True) # Built afresh every time. + for name in shared: + own = re.match(r"^[a-z]+_(.+)\.\w+$", name) + if own and own.group(1) in games and own.group(1) != game: + continue + place(os.path.join(source, name), os.path.join(folder, name)) + for name in [game + ".forge", "frame_%s.txt" % game, "cabinet_%s.png" % game]: + place(os.path.join(source, name), os.path.join(folder, name)) + writeDat(game, [portEntry(dat[game + ".singe"], game, game, ACTIONMAX)]) + builds.append((os.path.join(folder, game + ".forge"), os.path.join(folder, game + ".port.singe"))) + print("assembled %s" % game) + + # The KarisFramework games: the folder as the library has it, less the framework. A + # description under a Script/ folder (the map-mode games kept as Script, Cfg, Overlay, + # Fonts, and Sounds) takes the whole game folder, since the game reads all of it. + for path in sorted(glob.glob(os.path.join(LIBRARY, "*", "*.forge")) + glob.glob(os.path.join(LIBRARY, "*", "Script", "*.forge"))): + inner = "Script" if os.path.basename(os.path.dirname(path)) == "Script" else "" + name = os.path.basename(os.path.dirname(os.path.dirname(path)) if inner else os.path.dirname(path)) + if name in (ACTIONMAX, HYPSEUS): + continue + folder = os.path.join(OUT, name) + stems = sorted(os.path.splitext(os.path.basename(desc))[0] for desc in glob.glob(os.path.join(os.path.dirname(path), "*.forge"))) + first = (os.path.basename(path) == stems[0] + ".forge") + if first: + shutil.rmtree(folder, ignore_errors=True) # Built afresh every time. + # A hand-written title (one with a vocabulary beside its description) loads its own + # fonts, which a framework title's port never does. + leave = set(NOT_KARIS) - ({"Fonts"} if os.path.isfile(os.path.join(os.path.dirname(path), "Vocabulary.singe")) else set()) + placeTree(os.path.join(LIBRARY, name), folder, {"games.dat"} if inner else leave) + if os.path.isfile(os.path.join(LIBRARY, name, "games.dat")): + dat = entries(os.path.join(LIBRARY, name, "games.dat")) + else: + # A game that never had a games.dat (the library's American Laser Games titles): + # its launcher names the video and the script. + dat = {} + for launcher in glob.glob(os.path.join(LIBRARY, "*.sh")): + for video, script in re.findall(r'-v\s+(\S+)\s+(%s/\S+\.singe)' % re.escape(name), open(launcher).read()): + stem = os.path.splitext(os.path.basename(script))[0] + if stem + ".singe" not in dat: + dat[stem + ".singe"] = '{\n\t\tTITLE = "%s",\n\t\tSCRIPT = "%s",\n\t\tVIDEO = "%s",\n\t\tDATA = "%s"\n\t}' % (name, script, video, name) + # A title without a launcher of its own (the library starts SpacePiratesHD from + # SpacePirates.sh and never the other): the one video under its Video/. + for stem in stems: + videos = glob.glob(os.path.join(LIBRARY, name, "Video", "*.mp4")) + if (stem + ".singe" not in dat) and (len(videos) == 1): + dat[stem + ".singe"] = '{\n\t\tTITLE = "%s",\n\t\tSCRIPT = "%s/Script/%s.singe",\n\t\tVIDEO = "%s/Video/%s",\n\t\tDATA = "%s"\n\t}' % (name, name, stem, name, os.path.basename(videos[0]), name) + if first: + writeDat(name, [portEntry(dat[stem + ".singe"], name, (inner + "/" + stem) if inner else stem, name) for stem in stems if stem + ".singe" in dat]) + print("assembled %s (%s)" % (name, ", ".join(stems))) + builds.append((os.path.join(folder, inner, os.path.basename(path)), os.path.join(folder, inner, os.path.splitext(os.path.basename(path))[0] + ".port.singe"))) + + # The Hypseus titles: the game's folder flattened, the framefile and its Video/ beside it. + for path in sorted(glob.glob(os.path.join(LIBRARY, HYPSEUS, "*", "singe", "*", "*.forge"))): + name = os.path.basename(os.path.dirname(path)) + title = os.path.basename(os.path.dirname(os.path.dirname(os.path.dirname(path)))) + stem = os.path.splitext(os.path.basename(path))[0] + folder = os.path.join(OUT, title) + # A flat title links singe/<Title> back to itself: its files are the title's own folder, + # less what Hypseus put there (the launcher, its data, the archive, and the links). + flat = os.path.islink(os.path.dirname(path)) + source = os.path.realpath(os.path.dirname(path)) + shutil.rmtree(folder, ignore_errors=True) + # A hand-written title (one with a vocabulary beside its description) loads its own + # fonts, which a framework title's port never does. + leave = set(NOT_KARIS) - ({"Fonts"} if os.path.isfile(os.path.join(os.path.dirname(path), "Vocabulary.singe")) else set()) + placeTree(source, folder, leave | ({"singe", "Singe", "data", "Menu.sh", "MANIFEST.md"} if flat else set()), (".zip",) if flat else ()) + # The framefile at the top of the title (with exactly the videos it names, loose beside + # it or under Video/), or the one beside the script, already copied with the game. + frame = None + for candidate in glob.glob(os.path.join(LIBRARY, HYPSEUS, title, "*.txt")): + if isFramefile(candidate): + place(candidate, os.path.join(folder, os.path.basename(candidate))) + for video in framefileVideos(candidate): + if os.path.isfile(os.path.join(LIBRARY, HYPSEUS, title, video)): + place(os.path.join(LIBRARY, HYPSEUS, title, video), os.path.join(folder, video)) + frame = os.path.basename(candidate) + if frame is None and os.path.isfile(os.path.join(folder, stem + ".txt")): + frame = stem + ".txt" + for video in framefileVideos(os.path.join(folder, stem + ".txt")): + source = os.path.join(os.path.dirname(path), video) + if os.path.isfile(source) and not os.path.exists(os.path.join(folder, video)): + place(source, os.path.join(folder, video)) + shown = re.search(r'singeSetGameName\("([^"]*)"\)', open(os.path.join(os.path.dirname(path), stem + ".singe"), errors="replace").read()) + writeDat(title, ['{\n\t\tTITLE = "%s",\n\t\tSCRIPT = "%s/%s.port.singe",\n\t\tVIDEO = "%s/%s",\n\t\tDATA = "%s"\n\t}' % (shown.group(1) if shown else title, title, stem, title, frame or (title + ".txt"), title)]) + builds.append((os.path.join(folder, stem + ".forge"), os.path.join(folder, stem + ".port.singe"))) + print("assembled %s (Hypseus)" % title) + + # The compiler is Forge's, so the engine runs it: every description built into its folder, + # from a directory with Forge beside Singe, never the repository. + run = tempfile.mkdtemp(prefix="forgePorts.") + shutil.copytree(os.path.join(REPO, "assets", "Forge"), os.path.join(run, "Forge")) + with open(os.path.join(run, "build.singe"), "w") as script: + script.write('dofile("Forge/AuthorCompile.singe")\n') + for description, port in builds: + script.write('if authorBuild(%s, %s) == nil then debugPrint("PORTS FAIL %s") end\n' % (luaString(description), luaString(port), os.path.basename(port))) + script.write('singeQuit()\n') + log = subprocess.run([engine(), "-w", "-p", "-k", "-d", os.path.join(run, "data"), os.path.join(run, "build.singe")], cwd=run, env=dict(os.environ, SDL_VIDEODRIVER=os.environ.get("SDL_VIDEODRIVER", "offscreen"), SDL_AUDIO_DRIVER="dummy"), capture_output=True, text=True, timeout=600) + problems = [line for line in (log.stdout + log.stderr).splitlines() if "FAIL" in line or "rror" in line or "Author:" in line] + for line in problems: + print(line) + missing = [port for _, port in builds if not os.path.exists(port)] + shutil.rmtree(run, ignore_errors=True) + if missing or problems: + sys.exit("forgePorts: %d of %d ports did not build" % (len(missing), len(builds))) + print("built %d ports into %s" % (len(builds), OUT)) + + +main() diff --git a/util/makeMenuSound.py b/util/makeMenuSound.py index b089d93dd..db71b4ce2 100644 --- a/util/makeMenuSound.py +++ b/util/makeMenuSound.py @@ -1,9 +1,13 @@ # The menu backdrop's sound: the sting the intro runs to, with a bed underneath it that fades out -# as the menu takes the screen. It is written from nothing -- there is no sample library here, and -# an engine shipping media nobody can account for is exactly what the drawn backdrop was made to -# stop -- so every noise below is an oscillator, a burst of noise, or an envelope over one of the -# two. numpy does the arithmetic, as it does for the model tools; the reverb is a convolution and -# wants the FFT. +# as the menu takes the screen. The three things the picture shows -- the charge going off, the +# logo bursting out of it, the dragon rearing and breathing fire -- are recordings, three sounds +# from soundbible.com under the Creative Commons Attribution 3.0 licence, kept as FLAC under +# assets/samples and credited in LICENSES: Mark DiAngelo's "Explosion Ultra Bass", Mike Koenig's +# "Large Fireball", and Daniel Simon's "Dragon Fire Breath and Roar". They replaced the blast and +# the flame this script used to synthesise, which never sounded like the things themselves. What +# is between them -- the wingbeats, the riser as the grid comes up, the logo leaving, and the bed +# -- is still made here from oscillators and noise, in a room that is a convolution, and numpy does +# the arithmetic as it does for the model tools. # # One file comes out of it, menuIntro.flac, exactly as long as the backdrop's intro. Nothing loops: # music under a menu that is waiting for someone to choose a game wears out its welcome in about @@ -43,6 +47,7 @@ FLAP_FAST = 13.0 FLAP_SLOW = 3.4 BREATH_START = 3.46 BREATH_END = 4.35 +REAR_DONE = 4.90 # The head is back where it started; the dragon's fire has gone by here. GRID_UP = 4.30 GRID_LIT = 5.60 LIFT_FROM = 5.90 @@ -67,8 +72,25 @@ ROOM_DECAY = 0.85 # Seconds the room falls by a factor of e. ROOM_DARK = 1500 # It loses its top as it goes, the way a room does, and a long way off # only the bottom of a blast is left at all. ROOM_PREDELAY = 0.014 # The dry sound is heard on its own first, or it arrives already blurred. -CHARGE_DRIVE = 0.9 # How hard the blast and the flame are saturated. Gently: it is for -FLAME_DRIVE = 1.3 # grit, and anything more flattens the very dynamics that are the punch. +SAMPLES = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets", "samples") +EXPLOSION_LEVEL = 1.0 # The recordings against each other: the charge fills the scale, the +DRAGON_LEVEL = 1.2 # dragon is driven into the limiter over it, the fireball is a rush +FIREBALL_LEVEL = 0.6 # rather than a blow. +EXPLOSION_DRIVE = 1.6 # The explosion through a soft clipper, so its body is as loud as its + # crack and it hits rather than pops. +STING_PEAK = 1.3 # The sting above full scale before the limiter: the limiter bends + # everything over its knee towards the ceiling, so this is the loudness + # -- the punch -- of the whole intro. It was 1.15, and polite; at 2.2 + # the whole first four seconds were one flat wall. +EXPLOSION_HOLD = 0.4 # Seconds the explosion's rumble runs at full before it is let go, +EXPLOSION_GONE = 2.0 # and where it has gone: the roar is heard over silence, not rumble. + # (At 0.8 and 3.2 the rumble buried the roar altogether.) +DRAGON_FIRE_AT = 2.75 # Seconds into the dragon recording where its roar turns to fire; the + # recording is laid so that moment is the animation's BREATH_START. +DRAGON_ROAR_GAIN = 2.0 # The roar starts small in that recording and builds; its start is + # brought up so it is heard as the dragon flies in, not only as it rears. +DRAGON_FIRE_GAIN = 3.0 # The fire sits some eight decibels under the roar before it, and it is + # the fire the picture shows; brought up past it. ROOM_SLAPS = ((0.061, 0.30), (0.113, 0.20), (0.187, 0.12)) # Distinct returns off whatever is out # there. A blast in the open is heard once and then answered; a smooth # tail on its own is a plate reverb, which is a studio and not a place. @@ -112,21 +134,6 @@ def bandpass(signal, low, high): return highpass(lowpass(signal, high), low) -# A low pass whose cutoff moves, which no single convolution can do: a blast is a bright crack -# that turns into a rumble, and that turn is the filter closing. One pole, one multiply a sample, -# written out because the recursion cannot be vectorised. -def sweepLowpass(signal, cutoffs): - poles = np.exp(-2.0 * math.pi * np.clip(cutoffs, 10.0, RATE / 2.2) / RATE) - out = np.empty(len(signal)) - last = 0.0 - - for i, pole in enumerate(poles): - last = signal[i] * (1.0 - pole) + last * pole - out[i] = last - - return out - - # A two pole state variable filter with a moving cutoff, which rings at the cutoff as the resonance # goes up. The ring is the whole point of a riser: a swept resonance is what makes noise sound # like it is climbing towards something. @@ -146,36 +153,6 @@ def resonant(signal, cutoffs, resonance): return out -# A slow random wander between 0 and 1: noise with everything above a few hertz taken off it. -# This is what the fire and the blast are modulated by. The first attempt used a pair of sine -# waves instead and that is precisely what made them sound made up: nothing in a fire repeats. -def flutter(count, rate, rng): - shape = lowpass(noise(count, rng), rate) - shape -= shape.min() - worst = shape.max() - - return shape / worst if worst > 0.0 else shape - - -# Sparse pops: rubble coming down, or the spitting inside a flame. An impulse every so often at -# a random moment and a random size, each one smeared into a short burst of its own. Fire and -# debris are made of these, and a filtered hiss without them is a hiss. -def crackle(count, perSecond, rng, low, high, length=0.05): - train = np.zeros(count) - at = rng.integers(0, count, size=max(int(perSecond * count / RATE), 1)) - - np.add.at(train, at, rng.uniform(0.2, 1.0, size=len(at)) * rng.choice((-1.0, 1.0), size=len(at))) - burst = noise(int(length * RATE), rng) * np.exp(-seconds(int(length * RATE)) / (length / 4.0)) - - return bandpass(convolve(train, burst), low, high) - - -# A band pass whose band moves, for a flame whose resonance wanders. Two sweeping one poles: the -# lower one taken off the upper leaves what is between them. -def sweepBandpass(signal, low, high): - return sweepLowpass(signal, high) - sweepLowpass(signal, low) - - # An envelope follower: fast to rise, slow to fall, which is how a compressor hears a sound and # how the blast gets to push everything else out of its way. def follow(signal, attack, release): @@ -278,115 +255,71 @@ def flapTimes(until): return t[:-1][falling] -# The charge. This is the one sound in the intro that has to be felt rather than heard, and the -# first two attempts were not. The first peaked the meter with a sine at thirty hertz, which moves -# no air at all on the speaker a cabinet or a laptop actually has. The second had the weight and -# still sounded made up, for the reason every synthesised blast sounds made up: smooth envelopes -# over steady noise. Nothing about an explosion is smooth or steady. What is here now is five -# layers with the detail put back -- a crack, a slam through the middle of the bass, a drop that -# falls away twice over, a body whose level flickers at random as it closes, and rubble coming -# down afterwards -- and then the sum of them driven into a soft clipper, which is what a blast -# does to whatever is recording it and what turns five layers into one sound rather than five. -def chargeTake(length, rng): - count = int(length * RATE) - t = seconds(count) - out = np.zeros(count) +# A recording, decoded to this script's rate as one channel and brought to full scale, so what is +# laid into the sting is the sound and the level is the level asked for. +def sample(name): + path = os.path.join(SAMPLES, name + ".flac") + raw = subprocess.run(["ffmpeg", "-loglevel", "error", "-i", path, "-f", "f32le", "-ac", "1", "-ar", str(RATE), "-"], check=True, capture_output=True).stdout - # The crack: a fraction of a millisecond to full scale, and gone in twenty. This is the part - # that is heard as the thing having happened suddenly. - out += highpass(noise(count, rng), 1600) * hit(0.0002, 0.02, length) * 1.8 - - # The slam: two hundred to nine hundred hertz, gone in a twentieth of a second. It is this - # band, not the sub, that a small speaker turns into "loud". - out += bandpass(noise(count, rng), 200, 900) * hit(0.0008, 0.05, length) * 1.8 - - # The drop. Two decays rather than one -- most of it goes in a tenth of a second and the rest - # takes a second to follow -- because a single exponential is heard as a synthesiser's kick. - # Saturated, so the harmonics carry the pitch that a small speaker cannot make. - drop = sweep(33.0 + 150.0 * np.exp(-t / 0.16)) * (0.72 * np.exp(-t / 0.085) + 0.28 * np.exp(-t / 0.85)) - out += np.tanh(drop * 3.4) / math.tanh(3.4) * 1.0 - - # The body: noise with its top closing, flickering at random as it goes. The flicker is the - # difference between a blast and a cymbal. - body = sweepLowpass(noise(count, rng), 4200.0 * np.exp(-t / 0.26) + 80.0) * hit(0.001, 0.5, length) - out += body * (0.35 + 0.65 * flutter(count, 26, rng)) * 1.3 - - # Rubble. - out += crackle(count, 110, rng, 160, 3000) * hit(0.12, 1.5, length) * 0.8 - - # A little grit, and only a little. Driving the sum of the layers hard is how the second - # attempt lost its punch altogether: everything from the crack to the last of the rubble came - # out at the same level, which is a wall and not a blast. The sum is brought to full scale - # first so the amount of drive is the amount asked for rather than however many layers happen - # to be sounding at that instant. - return drive(out, CHARGE_DRIVE) + return normalise(np.frombuffer(raw, dtype="<f4").astype(np.float64), 1.0) -# The dragon's fire. Filtered noise with a tremolo on it is the textbook fake flame, and that is -# what this was: a band of noise wobbled by two sine waves. A flame is three things happening at -# once and none of them is periodic -- air catching alight, a roar whose loudness wanders at random, -# and the spitting inside it -- and the resonance of the column moves the whole time, which is why -# the band pass here is swept by a random walk rather than fixed. -def flameTake(length, rng): - count = int(length * RATE) - t = seconds(count) - out = np.zeros(count) - - # The air catching: a bright burst that closes almost at once. - out += sweepLowpass(noise(count, rng), 6000.0 * np.exp(-t / 0.07) + 320.0) * hit(0.005, 0.11, length) * 1.1 - - # The roar, and the column's own resonance wandering over it. - loud = 0.30 + 0.70 * flutter(count, 9, rng) - out += lowpass(noise(count, rng), 420) * loud * 1.5 - centre = 520.0 * (1.0 + 1.4 * flutter(count, 5, rng)) - out += sweepBandpass(noise(count, rng), centre * 0.55, centre * 2.2) * loud * 1.2 - - # The spitting, and the hiss of the jet. Both go with the roar rather than running underneath - # it at a level of their own: what a flame does is surge, and everything in it surges together. - out += crackle(count, 190, rng, 700, 7000) * loud * 0.5 - out += highpass(noise(count, rng), 4500) * loud * 0.35 - - return drive(out, FLAME_DRIVE) +# A recording let go: as it is until `hold` seconds, then away to nothing by `gone`, and cut there. +def letGo(signal, hold, gone): + return fade(signal[:int(gone * RATE)].copy(), hold) -# The sting: the charge and everything that comes out of it, in a room. The bed is not in here; -# it goes underneath afterwards. +# The sting: the recordings where the picture has something to show, what is made here between +# them in a room, and everything getting out of the charge's way. The bed is not in here; it goes +# underneath afterwards. def stingTake(length, rng): count = int(length * RATE) - out = np.zeros(count) + made = np.zeros(count) + heard = np.zeros(count) charge = np.zeros(count) + dragonAt = BREATH_START - DRAGON_FIRE_AT - lay(charge, chargeTake(min(3.0, length), rng), 0.0, 1.0) + # The charge: the explosion from its first sample, its rumble let go over the seconds after, + # so the dragon is heard over what is left of it rather than through it. + blast = np.tanh(sample("explosionUltraBass") * EXPLOSION_DRIVE) / math.tanh(EXPLOSION_DRIVE) + lay(charge, letGo(blast, EXPLOSION_HOLD, EXPLOSION_GONE), 0.0, EXPLOSION_LEVEL) + # The logo coming out of the blast: the fireball, leaving as the logo does. + lay(heard, sample("largeFireball"), FLY_START, FIREBALL_LEVEL) - # The logo coming out of the blast: a rush that rises as it arrives and stops when it stops. - lay(out, bandpass(noise(int(1.65 * RATE), rng), 300, 4000) * swell(1.65, 0.5, 0.45), FLY_START, 0.26) + # The dragon: the roar as it flies in and rears, the fire on the breath. The recording is + # laid so the moment its roar turns to fire is the moment the animation's does, and its fire + # is let go as the head comes back. + dragon = sample("dragonFireBreathAndRoar") + dragonT = seconds(len(dragon)) + # The roar's gain eases from its start to nothing extra by the fire; the fire's steps in there. + dragon *= 1.0 + (DRAGON_ROAR_GAIN - 1.0) * np.clip(1.0 - dragonT / DRAGON_FIRE_AT, 0.0, 1.0) + dragon *= 1.0 + (DRAGON_FIRE_GAIN - 1.0) * np.clip((dragonT - DRAGON_FIRE_AT) / 0.05, 0.0, 1.0) + lay(heard, letGo(dragon, BREATH_END - dragonAt, REAR_DONE - dragonAt), dragonAt, DRAGON_LEVEL) # Wings. gust = bandpass(noise(int(0.35 * RATE), rng), 180, 2200) * hit(0.03, 0.12, 0.35) for at in flapTimes(length): - lay(out, gust, at, 0.16) - - # The flame. - burn = BREATH_END - BREATH_START - lay(out, flameTake(burn, rng) * swell(burn, 0.05, 0.4), BREATH_START, 0.5) + lay(made, gust, at, 0.16) # The grid coming up out of the dark, twice over: a note that rises with it, and noise through # a resonance climbing the same way, which is the sound of something being switched on. climb = GRID_LIT - GRID_UP count = int(climb * 1.1 * RATE) ramp = np.clip(seconds(count) / climb, 0.0, 1.0) - lay(out, sweep(note(-12) * (1.0 + ramp * 3.0)) * swell(climb * 1.1, 0.9, 0.5), GRID_UP, 0.11) - lay(out, resonant(noise(count, rng), 180.0 * (1.0 + ramp * 32.0), 9.0) * swell(climb * 1.1, 1.0, 0.35), GRID_UP, 0.11) + lay(made, sweep(note(-12) * (1.0 + ramp * 3.0)) * swell(climb * 1.1, 0.9, 0.5), GRID_UP, 0.11) + lay(made, resonant(noise(count, rng), 180.0 * (1.0 + ramp * 32.0), 9.0) * swell(climb * 1.1, 1.0, 0.35), GRID_UP, 0.11) # The logo leaving. - lay(out, bandpass(noise(int((LIFT_TO - LIFT_FROM) * RATE), rng), 2000, 11000) * swell(LIFT_TO - LIFT_FROM, 0.5, 0.6), LIFT_FROM, 0.08) + lay(made, bandpass(noise(int((LIFT_TO - LIFT_FROM) * RATE), rng), 2000, 11000) * swell(LIFT_TO - LIFT_FROM, 0.5, 0.6), LIFT_FROM, 0.08) - # Everything else gets out of the charge's way and comes back over the next quarter second. - out = duck(out, charge, 0.8, 0.003, 0.22) + charge + # What was made here gets out of the charge's way and comes back over the next quarter second; + # the recordings do not, since the duck follows the whole of the explosion's rumble and took + # the roar down with it. The room is for what was made here; the recordings carry their own. + made = duck(made, charge, 0.8, 0.003, 0.22) space = room(rng) - return out + convolve(out, space) * 0.42 + return made + convolve(made, space) * 0.42 + heard + charge # The bed: a pad, a bass and an arpeggio, bar after bar for as long as it is wanted. It is played @@ -429,15 +362,6 @@ def bedTake(length, beat, rng): return out -# Saturation, over a signal brought to full scale first. tanh on its own is only a soft clipper -- -# how much it does depends entirely on how loud what goes into it happens to be -- and that is not -# a control, it is an accident. -def drive(signal, amount): - signal = normalise(signal, 1.0) - - return np.tanh(signal * amount) / math.tanh(amount) - - def normalise(signal, peak): worst = float(np.max(np.abs(signal))) @@ -497,10 +421,11 @@ def makeSound(introSeconds, folder): path = os.path.join(folder, "menuIntro.flac") beat = LIFT_TO / (BARS_TO_MENU * BEATS) bed = normalise(bedTake(introSeconds, beat, rng), BED_PEAK) - # Only a little above full scale: enough that the loudest moment meets the limiter and nothing - # else does. At 1.5 the limiter was holding the whole first second at the same level, which - # took the shape out of the blast as surely as over-driving it had. - sting = normalise(stingTake(introSeconds, rng), 1.15) + # Not normalised: every recording is already at full scale and laid at its level, so the + # blast keeps the ceiling whatever the dragon is driven to. (Normalising the sum handed the + # scale to whichever was loudest, and the last turn of the dragon's level took the blast down + # with it.) + sting = stingTake(introSeconds, rng) * STING_PEAK # The bed comes up under the flame and is established by the time the grid is. rising = np.clip((seconds(len(bed)) - BREATH_START) / (GRID_UP - BREATH_START), 0.0, 1.0) diff --git a/util/renderMenuAd.py b/util/renderMenuAd.py new file mode 100644 index 000000000..f3f259de8 --- /dev/null +++ b/util/renderMenuAd.py @@ -0,0 +1,137 @@ +# Renders the menu's intro -- Singe/Backdrop.singe from the charge to the moment the music has +# gone -- as one high-quality H.264 file with its sound, for use outside the engine: an advert, a +# trailer, a page. The same headless recording util/renderMenuVideo.py makes for the engine's own +# copy, at whatever size and rate is asked for, with the tagline laid out at that size rather than +# scaled up from the menu's, and assets/menuIntro.flac muxed in as AAC. +# +# The engine's offscreen video driver stops at 1024x768, so anything larger is recorded under a +# virtual X display (xvfb-run), where the hardware Vulkan device cannot present and SDL falls back +# to the software rasteriser. That is slow -- a frame is a second or two at 1080p -- and it is the +# same picture, so it is worth the wait. +# +# The rate has to divide the intro into whole frames: the intro ends at 9.90 seconds, so a 20 ms +# step (50 fps) or a 33 ms step (30.3 fps) works and a 17 ms step does not; the script says so. +# +# Usage: python3 util/renderMenuAd.py (1920x1080 at 50 fps) +# python3 util/renderMenuAd.py --width 3840 --height 2160 --step 20 --out intro4k.mp4 +import argparse +import os +import re +import shutil +import subprocess +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +OFFSCREEN_W = 1024 # The biggest the offscreen video driver gives; larger goes under Xvfb. +OFFSCREEN_H = 768 +CRF = 15 # Near transparent for flat colour and neon, the file stays small anyway. +AUDIO_BITRATE = "320k" + +DRIVER = '''-- Written by util/renderMenuAd.py. Records Singe/Backdrop.singe's intro frame by frame. +dofile("Singe/Backdrop.singe") + +local STEP = %(step)d / 1000.0 +local introFrames = backdropLoopAt() / STEP + +if math.abs(introFrames - math.floor(introFrames + 0.5)) > 0.0001 then + error(string.format("the intro ends at %%g seconds, which is not a frame at %%d ms; try 20 or 33", backdropLoopAt(), %(step)d)) +end +introFrames = math.floor(introFrames + 0.5) +local frame = 0 + +debugPrint(string.format("RENDER intro=%%d step=%%d", introFrames, %(step)d)) +overlaySetResolution(%(width)d, %(height)d) +backdropBegin(true) + + +function onOverlayUpdate() + colorBackground(0, 0, 0, 0) + overlayClear() + backdropFrame() + singeScreenshot() + frame = frame + 1 + if frame >= introFrames then + singeQuit() + end + + return OVERLAY_UPDATED +end +''' + + +def findBinary(): + folder = os.path.join(REPO, ".builddir", "linux-gcc", "x86_64", "singe") + for name in sorted(os.listdir(folder)): + path = os.path.join(folder, name) + if name.startswith("Singe") and os.path.isfile(path) and os.access(path, os.X_OK): + return path + raise SystemExit("no Singe binary in %s; build first" % folder) + + +def render(binary, work, frames, width, height, step): + env = dict(os.environ) + env["SDL_AUDIO_DRIVER"] = "dummy" + env["SDL_AUDIODRIVER"] = "dummy" + command = [binary, "-k", "-s", "-C", "%dx%d" % (width, height), "-x", str(width), "-y", str(height), + "--deterministic=%d" % step, "-d", frames + os.sep, "render.singe"] + if (width > OFFSCREEN_W) or (height > OFFSCREEN_H): + command = ["xvfb-run", "-a", "-s", "-screen 0 %dx%dx24" % (width, height)] + command + else: + env["SDL_VIDEODRIVER"] = "offscreen" + result = subprocess.run(command, cwd=work, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + text = result.stdout.decode("utf-8", "replace") + if result.returncode != 0: + sys.stdout.write(text) + raise SystemExit("the engine failed with %d" % result.returncode) + return text + + +def main(): + parser = argparse.ArgumentParser(description="render the menu intro with its sound as one H.264 file") + parser.add_argument("--out", default=os.path.join(REPO, ".builddir", "SingeIntro.mp4")) + parser.add_argument("--width", type=int, default=1920) + parser.add_argument("--height", type=int, default=1080) + parser.add_argument("--step", type=int, default=20, help="milliseconds a frame: 20 is 50 fps, 33 is 30.3") + parser.add_argument("--crf", type=int, default=CRF) + parser.add_argument("--binary", help="the engine to record with, in place of the built one") + parser.add_argument("--keep", action="store_true", help="leave the frames behind") + args = parser.parse_args() + + binary = args.binary or findBinary() + work = os.path.join(REPO, ".builddir", "menuAd") + frames = os.path.join(work, "frames") + shutil.rmtree(frames, ignore_errors=True) + os.makedirs(frames) + with open(os.path.join(work, "render.singe"), "w") as out: + out.write(DRIVER % {"step": args.step, "width": args.width, "height": args.height}) + + print("recording %dx%d at %g fps with %s" % (args.width, args.height, 1000.0 / args.step, os.path.basename(binary))) + text = render(binary, work, frames, args.width, args.height, args.step) + found = re.search(r"RENDER intro=(\d+) step=(\d+)", text) + if not found: + sys.stdout.write(text) + raise SystemExit("the engine never said how long the intro is") + intro = int(found.group(1)) + mine = open(os.path.join(REPO, "assets", "Backdrop.singe"), "rb").read() + theirs = open(os.path.join(work, "Singe", "Backdrop.singe"), "rb").read() + if mine != theirs: + print("WARNING: the binary's Backdrop.singe is not the one in assets; rebuild and render again") + shots = [f for f in os.listdir(frames) if f.endswith(".png")] + if len(shots) != intro: + raise SystemExit("wanted %d frames and got %d" % (intro, len(shots))) + + # H.264 High at a near-transparent quality, 4:2:0 so anything plays it, the sound as AAC, and + # the index at the front so it streams. -shortest: the sound is written to the intro's length, + # but the frames are the authority. + command = ["ffmpeg", "-y", "-loglevel", "error", + "-framerate", "1000/%d" % args.step, "-i", os.path.join(frames, "singe%03d.png"), + "-i", os.path.join(REPO, "assets", "menuIntro.flac"), + "-c:v", "libx264", "-preset", "slow", "-crf", str(args.crf), "-profile:v", "high", "-pix_fmt", "yuv420p", + "-c:a", "aac", "-b:a", AUDIO_BITRATE, "-movflags", "+faststart", "-shortest", args.out] + subprocess.run(command, check=True) + if not args.keep: + shutil.rmtree(frames, ignore_errors=True) + print("%s: %d frames, %dx%d at %g fps, %.2f seconds" % (args.out, intro, args.width, args.height, 1000.0 / args.step, intro * args.step / 1000.0)) + + +main() diff --git a/util/shootMenu.py b/util/shootMenu.py new file mode 100644 index 000000000..70f1587d4 --- /dev/null +++ b/util/shootMenu.py @@ -0,0 +1,189 @@ +# Shoots the menu's pages for screenshots/menu: the game list and every service tool, through +# both of the menu's renderers -- the RmlUi document the GPU draws, and the plain overlay a machine +# without one falls back to (SDL_GPU_DRIVER=nothing). A driver script runs the bundled menu +# headless, waits for the intro, and presses the switches a person would: SERVICE for the tools, +# Button 1 into each one, Button 2 out, Down to the next. Each page is saved by name so a rerun +# replaces the picture, and the pictures of each renderer are tiled into one contact sheet. +# +# Usage: python3 util/shootMenu.py (both renderers) +# python3 util/shootMenu.py --renderer overlay +# +# The engine used is the gcc preset's, as util/renderMenuVideo.py uses; build it first. The +# menu shown is the one that binary carries, so a change to the menu's files needs a build before +# a shoot -- the script checks Menu.singe against assets and says so. +import argparse +import os +import shutil +import subprocess +import sys + + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +WIDTH = 1024 # The biggest the offscreen video driver gives. +HEIGHT = 768 +RENDERERS = ["document", "overlay"] +SHEET_TILE = "420x315" # A quarter of the window, four to a row. + +DRIVER = '''-- Runs the bundled menu and shoots its pages by name. Written by util/shootMenu.py. +dofile("Singe/Menu.singe") + +local menuUpdate = onOverlayUpdate +local steps = {} +local at = 0 +local settled = 0 +local frames = 0 + + +local function press(switch) + steps[#steps + 1] = { press = switch } +end + + +local function wait(count) + steps[#steps + 1] = { wait = count } +end + + +local function shoot(name) + steps[#steps + 1] = { shoot = name } + wait(3) +end + + +local function slug(name) + return name:lower():gsub("[^a-z0-9]+", "-") +end + + +-- The game list, then the tools: the list of them, and each in turn. +wait(30) +shoot("game-list") +press(SWITCH_DOWN) +wait(10) +shoot("game-list-second") +press(SWITCH_UP) +wait(10) +press(SWITCH_SERVICE) +wait(5) +shoot("00-service-tools") +for index, tool in ipairs(TOOLS) do + press(SWITCH_BUTTON1) + wait(45) + shoot(string.format("%02d-%s", index, slug(tool.name))) + press(SWITCH_BUTTON2) + wait(3) + press(SWITCH_DOWN) + wait(3) +end +press(SWITCH_SERVICE) +wait(5) + + +function onOverlayUpdate() + local result = menuUpdate() + + frames = frames + 1 + if MENU_RENDER.backdropReady() then + if at < #steps then + local step = steps[at + 1] + + if step.wait then + settled = settled + 1 + if settled >= step.wait then + settled = 0 + at = at + 1 + end + else + if step.press then + onInputPressed(step.press) + elseif step.shoot then + singeScreenshot(step.shoot) + end + at = at + 1 + end + else + debugPrint("SHOOT done") + singeQuit() + end + end + if frames > 3000 then + debugPrint("SHOOT gave up waiting for the menu") + singeQuit() + end + return result +end +''' + + +def findBinary(): + folder = os.path.join(REPO, ".builddir", "linux-gcc", "x86_64", "singe") + for name in sorted(os.listdir(folder)): + path = os.path.join(folder, name) + if name.startswith("Singe") and os.path.isfile(path) and os.access(path, os.X_OK): + return path + raise SystemExit("no Singe binary in %s; build first" % folder) + + +def sheet(renderer, folder): + names = sorted(f for f in os.listdir(folder) if f.endswith(".png")) + out = os.path.join(REPO, "screenshots", "menu", "sheet-%s.png" % renderer) + command = ["montage", "-label", "%t", "-tile", "4x", "-geometry", SHEET_TILE + "+8+8", "-background", "#202020", "-fill", "#e0e0e0"] + command += [os.path.join(folder, f) for f in names] + command.append(out) + subprocess.run(command, check=True) + return out + + +def shoot(binary, renderer): + work = os.path.join(REPO, ".builddir", "menuShots", renderer) + shots = os.path.join(work, "shots") + shutil.rmtree(shots, ignore_errors=True) + os.makedirs(shots) + # The menu finds its games in the folders beside it: the test scripts' games.dat is enough. + link = os.path.join(work, "testScripts") + if not os.path.islink(link): + os.symlink(os.path.join(REPO, "testScripts"), link) + with open(os.path.join(work, "shoot.singe"), "w") as out: + out.write(DRIVER) + + env = dict(os.environ) + env["SDL_VIDEODRIVER"] = "offscreen" + env["SDL_AUDIO_DRIVER"] = "dummy" + env["SDL_AUDIODRIVER"] = "dummy" + if renderer == "overlay": + env["SDL_GPU_DRIVER"] = "nothing" + command = [binary, "-k", "-w", "-x", str(WIDTH), "-y", str(HEIGHT), "-d", shots + os.sep, "-v", "Singe/menuBackground.mkv", "shoot.singe"] + result = subprocess.run(command, cwd=work, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + text = result.stdout.decode("utf-8", "replace") + with open(os.path.join(work, "shoot.log"), "w") as out: + out.write(text) + if (result.returncode != 0) or ("SHOOT done" not in text): + sys.stdout.write(text) + raise SystemExit("the %s shoot did not finish; see %s" % (renderer, os.path.join(work, "shoot.log"))) + mine = open(os.path.join(REPO, "assets", "Menu.singe"), "rb").read() + theirs = open(os.path.join(work, "Singe", "Menu.singe"), "rb").read() + if mine != theirs: + print("WARNING: the binary's Menu.singe is not the one in assets; rebuild and shoot again") + + folder = os.path.join(REPO, "screenshots", "menu", renderer) + shutil.rmtree(folder, ignore_errors=True) + os.makedirs(folder) + names = sorted(f for f in os.listdir(shots) if f.endswith(".png")) + for name in names: + shutil.copyfile(os.path.join(shots, name), os.path.join(folder, name)) + print("%s: %d pages in %s, sheet %s" % (renderer, len(names), folder, sheet(renderer, folder))) + + +def main(): + parser = argparse.ArgumentParser(description="shoot the menu's pages through both renderers") + parser.add_argument("--renderer", choices=RENDERERS, help="just this one") + parser.add_argument("--binary", help="the engine to shoot with, in place of the built one") + args = parser.parse_args() + + binary = args.binary or findBinary() + for renderer in ([args.renderer] if args.renderer else RENDERERS): + shoot(binary, renderer) + + +if __name__ == "__main__": + main()